@olenbetong/appframe-ds 1.0.3 → 1.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 +13 -0
- package/package.json +8 -7
- package/scripts/copyAssets.mjs +27 -55
- package/src/AfLookup/Lookup.css +129 -125
- package/src/AfLookup/Lookup.tsx +184 -162
- package/src/AfLookup/LookupCombobox.css +78 -78
- package/src/AfLookup/LookupCombobox.tsx +237 -237
- package/src/AfLookup/LookupEdit.tsx +130 -130
- package/src/AfLookup/LookupGrid.tsx +124 -124
- package/src/AfLookup/index.ts +5 -5
- package/src/autocomplete/Autocomplete.css +256 -256
- package/src/autocomplete/Autocomplete.tsx +320 -320
- package/src/autocomplete/AutocompleteFrame.tsx +168 -168
- package/src/autocomplete/Combobox.tsx +286 -286
- package/src/autocomplete/index.ts +4 -4
- package/src/autocomplete/types.ts +204 -204
- package/src/autocomplete/utils.ts +183 -183
- package/src/binding/BoundNumericTextField.tsx +102 -102
- package/src/binding/BoundTextField.tsx +49 -49
- package/src/binding/CancelButton.tsx +30 -30
- package/src/binding/CancelIconButton.tsx +30 -30
- package/src/binding/DataEditToolbar.tsx +92 -92
- package/src/binding/DeleteButton.tsx +35 -35
- package/src/binding/DeleteIconButton.tsx +35 -35
- package/src/binding/RefreshButton.tsx +31 -31
- package/src/binding/RefreshIconButton.tsx +33 -33
- package/src/binding/RefreshRowIconButton.tsx +33 -33
- package/src/binding/SaveButton.tsx +37 -37
- package/src/binding/SaveIconButton.tsx +38 -38
- package/src/container/Container.tsx +49 -49
- package/src/container/index.ts +1 -1
- package/src/cutoff/CutOffPicker.css +12 -0
- package/src/cutoff/CutOffPicker.tsx +165 -0
- package/src/cutoff/context.tsx +194 -0
- package/src/cutoff/dsCutOffDates.ts +21 -0
- package/src/cutoff/index.ts +4 -0
- package/src/cutoff/utils.test.ts +59 -0
- package/src/cutoff/utils.ts +276 -0
- package/src/filter/ChipListInput.tsx +79 -79
- package/src/filter/FieldFilterPanel.css +150 -150
- package/src/filter/FieldFilterPanel.tsx +309 -309
- package/src/filter/FilterBuilder.css +152 -152
- package/src/filter/FilterBuilder.test.tsx +153 -153
- package/src/filter/FilterBuilder.tsx +435 -435
- package/src/filter/FilterEditor.css +231 -231
- package/src/filter/FilterEditor.test.tsx +259 -259
- package/src/filter/FilterEditor.tsx +75 -74
- package/src/filter/FilterGroupEditor.tsx +160 -160
- package/src/filter/FilterNameDialog.css +22 -22
- package/src/filter/FilterNameDialog.tsx +81 -81
- package/src/filter/FilterRow.tsx +117 -117
- package/src/filter/FilterShareDialog.css +28 -28
- package/src/filter/FilterShareDialog.tsx +201 -201
- package/src/filter/FilterStringField.tsx +78 -77
- package/src/filter/FilterValueEditor.tsx +220 -220
- package/src/filter/SavedFilterTree.css +107 -107
- package/src/filter/SavedFilterTree.test.tsx +94 -94
- package/src/filter/SavedFilterTree.tsx +218 -222
- package/src/filter/fieldFilter.test.ts +158 -158
- package/src/filter/fieldFilter.ts +139 -139
- package/src/filter/index.ts +27 -27
- package/src/filter/types.ts +38 -38
- package/src/filter/useDistinctValues.test.ts +102 -102
- package/src/filter/useDistinctValues.ts +230 -230
- package/src/global.d.ts +11 -11
- package/src/grid/AfGridColumnsPanel.tsx +118 -118
- package/src/grid/AfGridContext.tsx +51 -51
- package/src/grid/AfGridError.tsx +46 -46
- package/src/grid/AfHeaderFilterCell.tsx +147 -151
- package/src/grid/AfHeaderFilterPanel.tsx +111 -111
- package/src/grid/GridEditLookup.css +12 -0
- package/src/grid/GridEditLookup.tsx +121 -0
- package/src/grid/Toolbar.tsx +233 -233
- package/src/grid/editing.ts +100 -100
- package/src/grid/filter.ts +78 -78
- package/src/grid/formatters.ts +48 -48
- package/src/grid/index.css +245 -245
- package/src/grid/index.tsx +489 -484
- package/src/grid/license.ts +48 -48
- package/src/grid/localization.ts +369 -369
- package/src/grid/slots/index.tsx +307 -307
- package/src/grid/slots/slots.css +64 -64
- package/src/grid/theme.tsx +138 -138
- package/src/grid/useAfColumns.tsx +371 -371
- package/src/grid/useAfCurrentIndex.ts +63 -63
- package/src/grid/useAfData.ts +20 -20
- package/src/grid/useAfFilter.ts +160 -160
- package/src/grid/useAfFilterFields.ts +55 -55
- package/src/grid/useAfGridApi.ts +71 -71
- package/src/grid/useAfKeyBindings.ts +36 -36
- package/src/grid/useAfNewItemRow.ts +258 -258
- package/src/grid/useAfPagination.ts +53 -53
- package/src/grid/useAfPersistedState.ts +230 -230
- package/src/grid/useAfRowEditModel.ts +145 -145
- package/src/grid/useAfRowGrouping.ts +47 -47
- package/src/grid/useAfServerAggregation.ts +145 -145
- package/src/grid/useAfSortModel.ts +84 -84
- package/src/i18n.ts +13 -0
- package/src/index.ts +14 -12
- package/src/input/InputAdornments.css +75 -75
- package/src/input/InputAdornments.tsx +102 -102
- package/src/input/index.ts +1 -1
- package/src/layout/AppMenu.tsx +74 -75
- package/src/layout/AppMenuDrawer.css +70 -70
- package/src/layout/AppMenuDrawer.tsx +122 -122
- package/src/layout/ErrorBoundary.tsx +162 -162
- package/src/layout/ResponsiveDialog.css +86 -0
- package/src/layout/ResponsiveDialog.tsx +55 -0
- package/src/layout/index.tsx +69 -68
- package/src/link/Link.tsx +23 -0
- package/src/link/index.ts +1 -0
- package/src/mdi/Mdi.css +138 -138
- package/src/mdi/Mdi.test.tsx +91 -87
- package/src/mdi/Mdi.tsx +141 -141
- package/src/mdi/MdiConfirmCloseDialog.tsx +47 -47
- package/src/mdi/MdiContext.tsx +59 -59
- package/src/mdi/MdiDocumentContext.tsx +60 -60
- package/src/mdi/MdiPanel.tsx +61 -61
- package/src/mdi/MdiRestore.test.tsx +86 -82
- package/src/mdi/MdiTab.tsx +30 -30
- package/src/mdi/controller.test.ts +382 -382
- package/src/mdi/controller.ts +656 -656
- package/src/mdi/dockviewHost.ts +132 -132
- package/src/mdi/fakeHost.ts +200 -200
- package/src/mdi/host.ts +44 -44
- package/src/mdi/ids.test.ts +20 -20
- package/src/mdi/ids.ts +18 -18
- package/src/mdi/index.ts +31 -31
- package/src/mdi/layoutRestore.test.ts +156 -156
- package/src/mdi/layoutRestore.ts +311 -311
- package/src/mdi/layoutSpec.test.ts +143 -143
- package/src/mdi/layoutSpec.ts +214 -214
- package/src/mdi/layoutStore.test.ts +93 -93
- package/src/mdi/layoutStore.ts +99 -99
- package/src/mdi/memorySettingsStore.ts +21 -21
- package/src/mdi/registry.ts +34 -34
- package/src/mdi/testTypes.ts +37 -37
- package/src/mdi/theme.ts +22 -22
- package/src/mdi/types.ts +214 -214
- package/src/page/Page.css +53 -53
- package/src/page/PageBlock.css +64 -64
- package/src/page/PageBlock.tsx +92 -92
- package/src/page/PageHeader.css +88 -88
- package/src/page/PageHeader.tsx +115 -116
- package/src/page/PageTitle.css +5 -0
- package/src/page/PageTitle.tsx +21 -0
- package/src/page/index.ts +4 -3
- package/src/paper/Paper.tsx +52 -52
- package/src/progress/LinearProgress.css +93 -93
- package/src/progress/LinearProgress.tsx +104 -104
- package/src/progress/index.ts +1 -1
- package/src/report-downloader/ReportDownloader.css +67 -67
- package/src/report-downloader/components/StatusItem.tsx +87 -86
- package/src/report-downloader/components/StatusList.tsx +28 -28
- package/src/report-downloader/index.ts +41 -41
- package/src/report-downloader/status.tsx +92 -92
- package/src/sortable/DropIndicator.css +51 -51
- package/src/sortable/DropIndicator.tsx +27 -27
- package/src/sortable/SortableItemList.css +21 -21
- package/src/sortable/SortableItemList.tsx +264 -264
- package/src/sortable/index.ts +8 -8
- package/src/sortable/reorderSortOrder.ts +126 -126
- package/src/sortable/sortOrder.ts +101 -101
- package/src/sortable/types.ts +9 -9
- package/src/sortable/useSortableContainer.ts +53 -53
- package/src/sortable/useSortableItem.ts +95 -95
- package/src/sortable/useSortableList.ts +117 -117
- package/src/test/happy-dom-document-class.ts +24 -24
- package/src/test/setup.ts +12 -12
- package/src/theme/useDsColorScheme.ts +53 -53
- package/tsconfig.build.json +9 -9
- package/tsconfig.json +8 -8
- package/vitest.config.ts +10 -10
|
@@ -1,320 +1,320 @@
|
|
|
1
|
-
import "./Autocomplete.css";
|
|
2
|
-
|
|
3
|
-
import { Autocomplete as BaseAutocomplete } from "@base-ui/react/autocomplete";
|
|
4
|
-
import { ChipRemovable } from "@digdir/designsystemet-react";
|
|
5
|
-
import { ChevronDownIcon } from "@navikt/aksel-icons";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
} from "
|
|
19
|
-
|
|
20
|
-
export interface AutocompleteProps<
|
|
21
|
-
Option,
|
|
22
|
-
Multiple extends boolean | undefined = false,
|
|
23
|
-
FreeSolo extends boolean | undefined = false,
|
|
24
|
-
> extends AutocompleteBaseProps<Option, Multiple> {
|
|
25
|
-
/**
|
|
26
|
-
* The selected option(s). Use together with `onChange` for a controlled
|
|
27
|
-
* component.
|
|
28
|
-
*/
|
|
29
|
-
value?: AutocompleteValue<Option, Multiple, FreeSolo>;
|
|
30
|
-
/**
|
|
31
|
-
* The initially selected option(s) when uncontrolled.
|
|
32
|
-
*/
|
|
33
|
-
defaultValue?: AutocompleteValue<Option, Multiple, FreeSolo>;
|
|
34
|
-
/**
|
|
35
|
-
* Called when the selection changes.
|
|
36
|
-
*/
|
|
37
|
-
onChange?: (
|
|
38
|
-
event: Event | undefined,
|
|
39
|
-
value: AutocompleteValue<Option, Multiple, FreeSolo>,
|
|
40
|
-
reason: AutocompleteChangeReason,
|
|
41
|
-
) => void;
|
|
42
|
-
/**
|
|
43
|
-
* Allows the value to be arbitrary text that is not part of `options`.
|
|
44
|
-
* Pressing <kbd>Enter</kbd> without a highlighted option commits the text.
|
|
45
|
-
* @default false
|
|
46
|
-
*/
|
|
47
|
-
freeSolo?: FreeSolo;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* A text input with suggestions, built on Base UI's `Autocomplete` and styled
|
|
52
|
-
* with Designsystemet tokens. The options are suggestions: with `freeSolo` the
|
|
53
|
-
* user may commit any text.
|
|
54
|
-
*
|
|
55
|
-
* The API follows MUI's `Autocomplete`: `options`, `value`/`onChange`,
|
|
56
|
-
* `inputValue`/`onInputChange`, `getOptionLabel`, `filterOptions`,
|
|
57
|
-
* `renderOption`, `groupBy`, `multiple` and `freeSolo` all behave the same way.
|
|
58
|
-
* Designsystemet field props (`label`, `description`, `error`, `data-size`,
|
|
59
|
-
* `data-color`) are supported on top.
|
|
60
|
-
*
|
|
61
|
-
* Use `Combobox` instead when the user must pick one of the options.
|
|
62
|
-
*
|
|
63
|
-
* @example
|
|
64
|
-
* ```jsx
|
|
65
|
-
* <Autocomplete
|
|
66
|
-
* freeSolo
|
|
67
|
-
* label="Search"
|
|
68
|
-
* options={suggestions}
|
|
69
|
-
* inputValue={query}
|
|
70
|
-
* onInputChange={(_event, value) => setQuery(value)}
|
|
71
|
-
* onChange={(_event, value) => runSearch(value)}
|
|
72
|
-
* />
|
|
73
|
-
* ```
|
|
74
|
-
*/
|
|
75
|
-
export function Autocomplete<
|
|
76
|
-
Option,
|
|
77
|
-
Multiple extends boolean | undefined = false,
|
|
78
|
-
FreeSolo extends boolean | undefined = false,
|
|
79
|
-
>(props: AutocompleteProps<Option, Multiple, FreeSolo>) {
|
|
80
|
-
let {
|
|
81
|
-
options,
|
|
82
|
-
multiple,
|
|
83
|
-
freeSolo,
|
|
84
|
-
value: valueProp,
|
|
85
|
-
defaultValue,
|
|
86
|
-
onChange,
|
|
87
|
-
inputValue: inputValueProp,
|
|
88
|
-
onInputChange,
|
|
89
|
-
getOptionLabel = defaultGetOptionLabel,
|
|
90
|
-
getOptionKey,
|
|
91
|
-
getOptionDisabled,
|
|
92
|
-
isOptionEqualToValue,
|
|
93
|
-
groupBy,
|
|
94
|
-
filterOptions,
|
|
95
|
-
renderOption,
|
|
96
|
-
renderChipLabel,
|
|
97
|
-
noOptionsText,
|
|
98
|
-
loadingText,
|
|
99
|
-
loading,
|
|
100
|
-
limit = -1,
|
|
101
|
-
autoHighlight,
|
|
102
|
-
openOnFocus = false,
|
|
103
|
-
open,
|
|
104
|
-
defaultOpen,
|
|
105
|
-
onOpenChange,
|
|
106
|
-
modal,
|
|
107
|
-
disabled,
|
|
108
|
-
readOnly,
|
|
109
|
-
required,
|
|
110
|
-
name,
|
|
111
|
-
id: idProp,
|
|
112
|
-
className,
|
|
113
|
-
style,
|
|
114
|
-
controlClassName,
|
|
115
|
-
popupClassName,
|
|
116
|
-
fullWidth,
|
|
117
|
-
disableClearable,
|
|
118
|
-
placeholder,
|
|
119
|
-
inputRef,
|
|
120
|
-
"aria-label": ariaLabel,
|
|
121
|
-
"data-size": size,
|
|
122
|
-
"data-color": color,
|
|
123
|
-
} = props;
|
|
124
|
-
|
|
125
|
-
type Value = AutocompleteValue<Option, Multiple, FreeSolo>;
|
|
126
|
-
|
|
127
|
-
let generatedId = useId();
|
|
128
|
-
let id = idProp ?? generatedId;
|
|
129
|
-
|
|
130
|
-
let emptyValue = (multiple ? [] : null) as Value;
|
|
131
|
-
let [value, setValue] = useControlled(valueProp, defaultValue ?? emptyValue);
|
|
132
|
-
let initialInputValue = multiple || value == null ? "" : getOptionLabel(value as Option);
|
|
133
|
-
let [inputValue, setInputValue] = useControlled(inputValueProp, initialInputValue);
|
|
134
|
-
let highlightedRef = useRef<Option | undefined>(undefined);
|
|
135
|
-
|
|
136
|
-
let filteredOptions = useMemo(() => {
|
|
137
|
-
let filter = (filterOptions ?? defaultFilterOptions) as (
|
|
138
|
-
options: readonly Option[],
|
|
139
|
-
state: { inputValue: string; getOptionLabel: (option: Option) => string },
|
|
140
|
-
) => Option[];
|
|
141
|
-
|
|
142
|
-
return filter(options, { inputValue, getOptionLabel });
|
|
143
|
-
}, [options, inputValue, filterOptions, getOptionLabel]);
|
|
144
|
-
|
|
145
|
-
let filteredItems = useMemo(
|
|
146
|
-
() => (groupBy ? groupOptions(filteredOptions, groupBy) : filteredOptions),
|
|
147
|
-
[filteredOptions, groupBy],
|
|
148
|
-
);
|
|
149
|
-
|
|
150
|
-
let selectedOptions = useMemo(() => (multiple ? ((value ?? []) as Option[]) : []), [multiple, value]);
|
|
151
|
-
|
|
152
|
-
function commit(next: Value, reason: AutocompleteChangeReason, event?: Event) {
|
|
153
|
-
setValue(next);
|
|
154
|
-
onChange?.(event, next, reason);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function handleSelectOption(option: Option, event?: Event) {
|
|
158
|
-
if (multiple) {
|
|
159
|
-
let alreadySelected = selectedOptions.some((item) => areOptionsEqual(item, option, isOptionEqualToValue));
|
|
160
|
-
if (alreadySelected) return;
|
|
161
|
-
|
|
162
|
-
commit([...selectedOptions, option] as Value, "selectOption", event);
|
|
163
|
-
setInputValue("");
|
|
164
|
-
onInputChange?.(event, "", "reset");
|
|
165
|
-
} else {
|
|
166
|
-
commit(option as unknown as Value, "selectOption", event);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function handleRemoveChip(option: Option) {
|
|
171
|
-
let next = selectedOptions.filter((item) => !areOptionsEqual(item, option, isOptionEqualToValue));
|
|
172
|
-
commit(next as Value, "removeOption");
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function handleInputKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
|
176
|
-
if (event.key !== "Enter" || !freeSolo || highlightedRef.current !== undefined) return;
|
|
177
|
-
|
|
178
|
-
let text = inputValue.trim();
|
|
179
|
-
if (!text) return;
|
|
180
|
-
|
|
181
|
-
if (multiple) {
|
|
182
|
-
commit([...selectedOptions, text] as Value, "createOption", event.nativeEvent);
|
|
183
|
-
setInputValue("");
|
|
184
|
-
onInputChange?.(event.nativeEvent, "", "reset");
|
|
185
|
-
} else {
|
|
186
|
-
commit(text as unknown as Value, "createOption", event.nativeEvent);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function handleInputBlur(event: React.FocusEvent<HTMLInputElement>) {
|
|
191
|
-
if (freeSolo) return;
|
|
192
|
-
|
|
193
|
-
// Without free text, the input only makes sense as a reflection of the
|
|
194
|
-
// current selection, so anything the user typed is discarded.
|
|
195
|
-
let text = multiple || value == null ? "" : getOptionLabel(value as Option);
|
|
196
|
-
if (text !== inputValue) {
|
|
197
|
-
setInputValue(text);
|
|
198
|
-
onInputChange?.(event.nativeEvent, text, "reset");
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
let chips =
|
|
203
|
-
multiple && selectedOptions.length > 0
|
|
204
|
-
? selectedOptions.map((option) => (
|
|
205
|
-
<ChipRemovable
|
|
206
|
-
key={getOptionKey?.(option) ?? getOptionLabel(option)}
|
|
207
|
-
data-size={size}
|
|
208
|
-
disabled={disabled || readOnly}
|
|
209
|
-
onClick={() => handleRemoveChip(option)}
|
|
210
|
-
>
|
|
211
|
-
{renderChipLabel?.(option) ?? getOptionLabel(option)}
|
|
212
|
-
</ChipRemovable>
|
|
213
|
-
))
|
|
214
|
-
: undefined;
|
|
215
|
-
|
|
216
|
-
function renderItem(option: Option, index: number) {
|
|
217
|
-
let selected = multiple
|
|
218
|
-
? selectedOptions.some((item) => areOptionsEqual(item, option, isOptionEqualToValue))
|
|
219
|
-
: value != null && areOptionsEqual(value as Option, option, isOptionEqualToValue);
|
|
220
|
-
|
|
221
|
-
return (
|
|
222
|
-
<BaseAutocomplete.Item
|
|
223
|
-
key={getOptionKey?.(option) ?? getOptionLabel(option)}
|
|
224
|
-
className="ObAutocomplete-item"
|
|
225
|
-
value={option}
|
|
226
|
-
index={index}
|
|
227
|
-
disabled={getOptionDisabled?.(option)}
|
|
228
|
-
data-selected={selected || undefined}
|
|
229
|
-
onClick={(event) => handleSelectOption(option, event.nativeEvent)}
|
|
230
|
-
>
|
|
231
|
-
<span className="ObAutocomplete-itemContent">
|
|
232
|
-
{renderOption?.(option, { index, inputValue, selected }) ?? getOptionLabel(option)}
|
|
233
|
-
</span>
|
|
234
|
-
</BaseAutocomplete.Item>
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function renderListItem(item: any, index: number) {
|
|
239
|
-
if (!groupBy) return renderItem(item as Option, index);
|
|
240
|
-
|
|
241
|
-
return (
|
|
242
|
-
<BaseAutocomplete.Group key={item.value} items={item.items}>
|
|
243
|
-
<BaseAutocomplete.GroupLabel className="ObAutocomplete-groupLabel">{item.value}</BaseAutocomplete.GroupLabel>
|
|
244
|
-
<BaseAutocomplete.Collection>
|
|
245
|
-
{(option: Option, optionIndex: number) => renderItem(option, optionIndex)}
|
|
246
|
-
</BaseAutocomplete.Collection>
|
|
247
|
-
</BaseAutocomplete.Group>
|
|
248
|
-
);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
return (
|
|
252
|
-
<BaseAutocomplete.Root
|
|
253
|
-
items={options as any}
|
|
254
|
-
filteredItems={filteredItems as any}
|
|
255
|
-
filter={null}
|
|
256
|
-
value={inputValue}
|
|
257
|
-
onValueChange={(next, details) => {
|
|
258
|
-
handleInputValueChange(next, details, setInputValue, onInputChange);
|
|
259
|
-
|
|
260
|
-
if (details.reason === "clear-press" || details.reason === "input-clear") {
|
|
261
|
-
commit(emptyValue, "clear", details.event);
|
|
262
|
-
}
|
|
263
|
-
}}
|
|
264
|
-
itemToStringValue={getOptionLabel as (option: unknown) => string}
|
|
265
|
-
onItemHighlighted={(item) => {
|
|
266
|
-
highlightedRef.current = item as Option | undefined;
|
|
267
|
-
}}
|
|
268
|
-
disabled={disabled}
|
|
269
|
-
readOnly={readOnly}
|
|
270
|
-
required={required}
|
|
271
|
-
name={name}
|
|
272
|
-
id={id}
|
|
273
|
-
open={open}
|
|
274
|
-
defaultOpen={defaultOpen}
|
|
275
|
-
onOpenChange={(nextOpen) => onOpenChange?.(nextOpen)}
|
|
276
|
-
openOnInputClick={openOnFocus}
|
|
277
|
-
autoHighlight={autoHighlight}
|
|
278
|
-
limit={limit}
|
|
279
|
-
modal={modal}
|
|
280
|
-
>
|
|
281
|
-
<AutocompleteFrame
|
|
282
|
-
id={id}
|
|
283
|
-
label={props.label}
|
|
284
|
-
description={props.description}
|
|
285
|
-
error={props.error}
|
|
286
|
-
className={className}
|
|
287
|
-
style={style}
|
|
288
|
-
controlClassName={controlClassName}
|
|
289
|
-
popupClassName={popupClassName}
|
|
290
|
-
fullWidth={fullWidth}
|
|
291
|
-
size={size}
|
|
292
|
-
color={color}
|
|
293
|
-
disabled={disabled}
|
|
294
|
-
readOnly={readOnly}
|
|
295
|
-
required={required}
|
|
296
|
-
placeholder={placeholder}
|
|
297
|
-
loading={loading}
|
|
298
|
-
disableClearable={disableClearable}
|
|
299
|
-
inputRef={inputRef}
|
|
300
|
-
aria-label={ariaLabel}
|
|
301
|
-
chips={chips}
|
|
302
|
-
trigger={
|
|
303
|
-
<BaseAutocomplete.Trigger
|
|
304
|
-
className="ObAutocomplete-button ObAutocomplete-trigger"
|
|
305
|
-
disabled={disabled || readOnly}
|
|
306
|
-
aria-label={getLocalizedString("Open")}
|
|
307
|
-
>
|
|
308
|
-
<ChevronDownIcon aria-hidden fontSize="1.25em" />
|
|
309
|
-
</BaseAutocomplete.Trigger>
|
|
310
|
-
}
|
|
311
|
-
emptyContent={
|
|
312
|
-
loading ? (loadingText ?? getLocalizedString("Loading")) : (noOptionsText ?? getLocalizedString("No options"))
|
|
313
|
-
}
|
|
314
|
-
renderListItem={renderListItem}
|
|
315
|
-
onInputKeyDown={handleInputKeyDown}
|
|
316
|
-
onInputBlur={handleInputBlur}
|
|
317
|
-
/>
|
|
318
|
-
</BaseAutocomplete.Root>
|
|
319
|
-
);
|
|
320
|
-
}
|
|
1
|
+
import "./Autocomplete.css";
|
|
2
|
+
|
|
3
|
+
import { Autocomplete as BaseAutocomplete } from "@base-ui/react/autocomplete";
|
|
4
|
+
import { ChipRemovable } from "@digdir/designsystemet-react";
|
|
5
|
+
import { ChevronDownIcon } from "@navikt/aksel-icons";
|
|
6
|
+
import { useId, useMemo, useRef } from "react";
|
|
7
|
+
|
|
8
|
+
import { AutocompleteFrame } from "./AutocompleteFrame.js";
|
|
9
|
+
import type { AutocompleteBaseProps, AutocompleteChangeReason, AutocompleteValue } from "./types.js";
|
|
10
|
+
import {
|
|
11
|
+
areOptionsEqual,
|
|
12
|
+
defaultFilterOptions,
|
|
13
|
+
defaultGetOptionLabel,
|
|
14
|
+
groupOptions,
|
|
15
|
+
handleInputValueChange,
|
|
16
|
+
useControlled,
|
|
17
|
+
} from "./utils.js";
|
|
18
|
+
import { getLocalizedString } from "../i18n.js";
|
|
19
|
+
|
|
20
|
+
export interface AutocompleteProps<
|
|
21
|
+
Option,
|
|
22
|
+
Multiple extends boolean | undefined = false,
|
|
23
|
+
FreeSolo extends boolean | undefined = false,
|
|
24
|
+
> extends AutocompleteBaseProps<Option, Multiple> {
|
|
25
|
+
/**
|
|
26
|
+
* The selected option(s). Use together with `onChange` for a controlled
|
|
27
|
+
* component.
|
|
28
|
+
*/
|
|
29
|
+
value?: AutocompleteValue<Option, Multiple, FreeSolo>;
|
|
30
|
+
/**
|
|
31
|
+
* The initially selected option(s) when uncontrolled.
|
|
32
|
+
*/
|
|
33
|
+
defaultValue?: AutocompleteValue<Option, Multiple, FreeSolo>;
|
|
34
|
+
/**
|
|
35
|
+
* Called when the selection changes.
|
|
36
|
+
*/
|
|
37
|
+
onChange?: (
|
|
38
|
+
event: Event | undefined,
|
|
39
|
+
value: AutocompleteValue<Option, Multiple, FreeSolo>,
|
|
40
|
+
reason: AutocompleteChangeReason,
|
|
41
|
+
) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Allows the value to be arbitrary text that is not part of `options`.
|
|
44
|
+
* Pressing <kbd>Enter</kbd> without a highlighted option commits the text.
|
|
45
|
+
* @default false
|
|
46
|
+
*/
|
|
47
|
+
freeSolo?: FreeSolo;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A text input with suggestions, built on Base UI's `Autocomplete` and styled
|
|
52
|
+
* with Designsystemet tokens. The options are suggestions: with `freeSolo` the
|
|
53
|
+
* user may commit any text.
|
|
54
|
+
*
|
|
55
|
+
* The API follows MUI's `Autocomplete`: `options`, `value`/`onChange`,
|
|
56
|
+
* `inputValue`/`onInputChange`, `getOptionLabel`, `filterOptions`,
|
|
57
|
+
* `renderOption`, `groupBy`, `multiple` and `freeSolo` all behave the same way.
|
|
58
|
+
* Designsystemet field props (`label`, `description`, `error`, `data-size`,
|
|
59
|
+
* `data-color`) are supported on top.
|
|
60
|
+
*
|
|
61
|
+
* Use `Combobox` instead when the user must pick one of the options.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```jsx
|
|
65
|
+
* <Autocomplete
|
|
66
|
+
* freeSolo
|
|
67
|
+
* label="Search"
|
|
68
|
+
* options={suggestions}
|
|
69
|
+
* inputValue={query}
|
|
70
|
+
* onInputChange={(_event, value) => setQuery(value)}
|
|
71
|
+
* onChange={(_event, value) => runSearch(value)}
|
|
72
|
+
* />
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export function Autocomplete<
|
|
76
|
+
Option,
|
|
77
|
+
Multiple extends boolean | undefined = false,
|
|
78
|
+
FreeSolo extends boolean | undefined = false,
|
|
79
|
+
>(props: AutocompleteProps<Option, Multiple, FreeSolo>) {
|
|
80
|
+
let {
|
|
81
|
+
options,
|
|
82
|
+
multiple,
|
|
83
|
+
freeSolo,
|
|
84
|
+
value: valueProp,
|
|
85
|
+
defaultValue,
|
|
86
|
+
onChange,
|
|
87
|
+
inputValue: inputValueProp,
|
|
88
|
+
onInputChange,
|
|
89
|
+
getOptionLabel = defaultGetOptionLabel,
|
|
90
|
+
getOptionKey,
|
|
91
|
+
getOptionDisabled,
|
|
92
|
+
isOptionEqualToValue,
|
|
93
|
+
groupBy,
|
|
94
|
+
filterOptions,
|
|
95
|
+
renderOption,
|
|
96
|
+
renderChipLabel,
|
|
97
|
+
noOptionsText,
|
|
98
|
+
loadingText,
|
|
99
|
+
loading,
|
|
100
|
+
limit = -1,
|
|
101
|
+
autoHighlight,
|
|
102
|
+
openOnFocus = false,
|
|
103
|
+
open,
|
|
104
|
+
defaultOpen,
|
|
105
|
+
onOpenChange,
|
|
106
|
+
modal,
|
|
107
|
+
disabled,
|
|
108
|
+
readOnly,
|
|
109
|
+
required,
|
|
110
|
+
name,
|
|
111
|
+
id: idProp,
|
|
112
|
+
className,
|
|
113
|
+
style,
|
|
114
|
+
controlClassName,
|
|
115
|
+
popupClassName,
|
|
116
|
+
fullWidth,
|
|
117
|
+
disableClearable,
|
|
118
|
+
placeholder,
|
|
119
|
+
inputRef,
|
|
120
|
+
"aria-label": ariaLabel,
|
|
121
|
+
"data-size": size,
|
|
122
|
+
"data-color": color,
|
|
123
|
+
} = props;
|
|
124
|
+
|
|
125
|
+
type Value = AutocompleteValue<Option, Multiple, FreeSolo>;
|
|
126
|
+
|
|
127
|
+
let generatedId = useId();
|
|
128
|
+
let id = idProp ?? generatedId;
|
|
129
|
+
|
|
130
|
+
let emptyValue = (multiple ? [] : null) as Value;
|
|
131
|
+
let [value, setValue] = useControlled(valueProp, defaultValue ?? emptyValue);
|
|
132
|
+
let initialInputValue = multiple || value == null ? "" : getOptionLabel(value as Option);
|
|
133
|
+
let [inputValue, setInputValue] = useControlled(inputValueProp, initialInputValue);
|
|
134
|
+
let highlightedRef = useRef<Option | undefined>(undefined);
|
|
135
|
+
|
|
136
|
+
let filteredOptions = useMemo(() => {
|
|
137
|
+
let filter = (filterOptions ?? defaultFilterOptions) as (
|
|
138
|
+
options: readonly Option[],
|
|
139
|
+
state: { inputValue: string; getOptionLabel: (option: Option) => string },
|
|
140
|
+
) => Option[];
|
|
141
|
+
|
|
142
|
+
return filter(options, { inputValue, getOptionLabel });
|
|
143
|
+
}, [options, inputValue, filterOptions, getOptionLabel]);
|
|
144
|
+
|
|
145
|
+
let filteredItems = useMemo(
|
|
146
|
+
() => (groupBy ? groupOptions(filteredOptions, groupBy) : filteredOptions),
|
|
147
|
+
[filteredOptions, groupBy],
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
let selectedOptions = useMemo(() => (multiple ? ((value ?? []) as Option[]) : []), [multiple, value]);
|
|
151
|
+
|
|
152
|
+
function commit(next: Value, reason: AutocompleteChangeReason, event?: Event) {
|
|
153
|
+
setValue(next);
|
|
154
|
+
onChange?.(event, next, reason);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function handleSelectOption(option: Option, event?: Event) {
|
|
158
|
+
if (multiple) {
|
|
159
|
+
let alreadySelected = selectedOptions.some((item) => areOptionsEqual(item, option, isOptionEqualToValue));
|
|
160
|
+
if (alreadySelected) return;
|
|
161
|
+
|
|
162
|
+
commit([...selectedOptions, option] as Value, "selectOption", event);
|
|
163
|
+
setInputValue("");
|
|
164
|
+
onInputChange?.(event, "", "reset");
|
|
165
|
+
} else {
|
|
166
|
+
commit(option as unknown as Value, "selectOption", event);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function handleRemoveChip(option: Option) {
|
|
171
|
+
let next = selectedOptions.filter((item) => !areOptionsEqual(item, option, isOptionEqualToValue));
|
|
172
|
+
commit(next as Value, "removeOption");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function handleInputKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
|
176
|
+
if (event.key !== "Enter" || !freeSolo || highlightedRef.current !== undefined) return;
|
|
177
|
+
|
|
178
|
+
let text = inputValue.trim();
|
|
179
|
+
if (!text) return;
|
|
180
|
+
|
|
181
|
+
if (multiple) {
|
|
182
|
+
commit([...selectedOptions, text] as Value, "createOption", event.nativeEvent);
|
|
183
|
+
setInputValue("");
|
|
184
|
+
onInputChange?.(event.nativeEvent, "", "reset");
|
|
185
|
+
} else {
|
|
186
|
+
commit(text as unknown as Value, "createOption", event.nativeEvent);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function handleInputBlur(event: React.FocusEvent<HTMLInputElement>) {
|
|
191
|
+
if (freeSolo) return;
|
|
192
|
+
|
|
193
|
+
// Without free text, the input only makes sense as a reflection of the
|
|
194
|
+
// current selection, so anything the user typed is discarded.
|
|
195
|
+
let text = multiple || value == null ? "" : getOptionLabel(value as Option);
|
|
196
|
+
if (text !== inputValue) {
|
|
197
|
+
setInputValue(text);
|
|
198
|
+
onInputChange?.(event.nativeEvent, text, "reset");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let chips =
|
|
203
|
+
multiple && selectedOptions.length > 0
|
|
204
|
+
? selectedOptions.map((option) => (
|
|
205
|
+
<ChipRemovable
|
|
206
|
+
key={getOptionKey?.(option) ?? getOptionLabel(option)}
|
|
207
|
+
data-size={size}
|
|
208
|
+
disabled={disabled || readOnly}
|
|
209
|
+
onClick={() => handleRemoveChip(option)}
|
|
210
|
+
>
|
|
211
|
+
{renderChipLabel?.(option) ?? getOptionLabel(option)}
|
|
212
|
+
</ChipRemovable>
|
|
213
|
+
))
|
|
214
|
+
: undefined;
|
|
215
|
+
|
|
216
|
+
function renderItem(option: Option, index: number) {
|
|
217
|
+
let selected = multiple
|
|
218
|
+
? selectedOptions.some((item) => areOptionsEqual(item, option, isOptionEqualToValue))
|
|
219
|
+
: value != null && areOptionsEqual(value as Option, option, isOptionEqualToValue);
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<BaseAutocomplete.Item
|
|
223
|
+
key={getOptionKey?.(option) ?? getOptionLabel(option)}
|
|
224
|
+
className="ObAutocomplete-item"
|
|
225
|
+
value={option}
|
|
226
|
+
index={index}
|
|
227
|
+
disabled={getOptionDisabled?.(option)}
|
|
228
|
+
data-selected={selected || undefined}
|
|
229
|
+
onClick={(event) => handleSelectOption(option, event.nativeEvent)}
|
|
230
|
+
>
|
|
231
|
+
<span className="ObAutocomplete-itemContent">
|
|
232
|
+
{renderOption?.(option, { index, inputValue, selected }) ?? getOptionLabel(option)}
|
|
233
|
+
</span>
|
|
234
|
+
</BaseAutocomplete.Item>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function renderListItem(item: any, index: number) {
|
|
239
|
+
if (!groupBy) return renderItem(item as Option, index);
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<BaseAutocomplete.Group key={item.value} items={item.items}>
|
|
243
|
+
<BaseAutocomplete.GroupLabel className="ObAutocomplete-groupLabel">{item.value}</BaseAutocomplete.GroupLabel>
|
|
244
|
+
<BaseAutocomplete.Collection>
|
|
245
|
+
{(option: Option, optionIndex: number) => renderItem(option, optionIndex)}
|
|
246
|
+
</BaseAutocomplete.Collection>
|
|
247
|
+
</BaseAutocomplete.Group>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<BaseAutocomplete.Root
|
|
253
|
+
items={options as any}
|
|
254
|
+
filteredItems={filteredItems as any}
|
|
255
|
+
filter={null}
|
|
256
|
+
value={inputValue}
|
|
257
|
+
onValueChange={(next, details) => {
|
|
258
|
+
handleInputValueChange(next, details, setInputValue, onInputChange);
|
|
259
|
+
|
|
260
|
+
if (details.reason === "clear-press" || details.reason === "input-clear") {
|
|
261
|
+
commit(emptyValue, "clear", details.event);
|
|
262
|
+
}
|
|
263
|
+
}}
|
|
264
|
+
itemToStringValue={getOptionLabel as (option: unknown) => string}
|
|
265
|
+
onItemHighlighted={(item) => {
|
|
266
|
+
highlightedRef.current = item as Option | undefined;
|
|
267
|
+
}}
|
|
268
|
+
disabled={disabled}
|
|
269
|
+
readOnly={readOnly}
|
|
270
|
+
required={required}
|
|
271
|
+
name={name}
|
|
272
|
+
id={id}
|
|
273
|
+
open={open}
|
|
274
|
+
defaultOpen={defaultOpen}
|
|
275
|
+
onOpenChange={(nextOpen) => onOpenChange?.(nextOpen)}
|
|
276
|
+
openOnInputClick={openOnFocus}
|
|
277
|
+
autoHighlight={autoHighlight}
|
|
278
|
+
limit={limit}
|
|
279
|
+
modal={modal}
|
|
280
|
+
>
|
|
281
|
+
<AutocompleteFrame
|
|
282
|
+
id={id}
|
|
283
|
+
label={props.label}
|
|
284
|
+
description={props.description}
|
|
285
|
+
error={props.error}
|
|
286
|
+
className={className}
|
|
287
|
+
style={style}
|
|
288
|
+
controlClassName={controlClassName}
|
|
289
|
+
popupClassName={popupClassName}
|
|
290
|
+
fullWidth={fullWidth}
|
|
291
|
+
size={size}
|
|
292
|
+
color={color}
|
|
293
|
+
disabled={disabled}
|
|
294
|
+
readOnly={readOnly}
|
|
295
|
+
required={required}
|
|
296
|
+
placeholder={placeholder}
|
|
297
|
+
loading={loading}
|
|
298
|
+
disableClearable={disableClearable}
|
|
299
|
+
inputRef={inputRef}
|
|
300
|
+
aria-label={ariaLabel}
|
|
301
|
+
chips={chips}
|
|
302
|
+
trigger={
|
|
303
|
+
<BaseAutocomplete.Trigger
|
|
304
|
+
className="ObAutocomplete-button ObAutocomplete-trigger"
|
|
305
|
+
disabled={disabled || readOnly}
|
|
306
|
+
aria-label={getLocalizedString("Open")}
|
|
307
|
+
>
|
|
308
|
+
<ChevronDownIcon aria-hidden fontSize="1.25em" />
|
|
309
|
+
</BaseAutocomplete.Trigger>
|
|
310
|
+
}
|
|
311
|
+
emptyContent={
|
|
312
|
+
loading ? (loadingText ?? getLocalizedString("Loading")) : (noOptionsText ?? getLocalizedString("No options"))
|
|
313
|
+
}
|
|
314
|
+
renderListItem={renderListItem}
|
|
315
|
+
onInputKeyDown={handleInputKeyDown}
|
|
316
|
+
onInputBlur={handleInputBlur}
|
|
317
|
+
/>
|
|
318
|
+
</BaseAutocomplete.Root>
|
|
319
|
+
);
|
|
320
|
+
}
|