@olenbetong/appframe-ds 0.6.0 → 0.7.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.
@@ -0,0 +1,316 @@
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 { getLocalizedString } from "@olenbetong/appframe-core";
7
+ import { useId, useMemo, useRef } from "react";
8
+
9
+ import { AutocompleteFrame } from "./AutocompleteFrame.js";
10
+ import type { AutocompleteBaseProps, AutocompleteChangeReason, AutocompleteValue } from "./types.js";
11
+ import {
12
+ areOptionsEqual,
13
+ defaultFilterOptions,
14
+ defaultGetOptionLabel,
15
+ groupOptions,
16
+ handleInputValueChange,
17
+ useControlled,
18
+ } from "./utils.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
+ "data-size": size,
121
+ "data-color": color,
122
+ } = props;
123
+
124
+ type Value = AutocompleteValue<Option, Multiple, FreeSolo>;
125
+
126
+ let generatedId = useId();
127
+ let id = idProp ?? generatedId;
128
+
129
+ let emptyValue = (multiple ? [] : null) as Value;
130
+ let [value, setValue] = useControlled(valueProp, defaultValue ?? emptyValue);
131
+ let initialInputValue = multiple || value == null ? "" : getOptionLabel(value as Option);
132
+ let [inputValue, setInputValue] = useControlled(inputValueProp, initialInputValue);
133
+ let highlightedRef = useRef<Option | undefined>(undefined);
134
+
135
+ let filteredOptions = useMemo(() => {
136
+ let filter = (filterOptions ?? defaultFilterOptions) as (
137
+ options: readonly Option[],
138
+ state: { inputValue: string; getOptionLabel: (option: Option) => string },
139
+ ) => Option[];
140
+
141
+ return filter(options, { inputValue, getOptionLabel });
142
+ }, [options, inputValue, filterOptions, getOptionLabel]);
143
+
144
+ let filteredItems = useMemo(
145
+ () => (groupBy ? groupOptions(filteredOptions, groupBy) : filteredOptions),
146
+ [filteredOptions, groupBy],
147
+ );
148
+
149
+ let selectedOptions = useMemo(() => (multiple ? ((value ?? []) as Option[]) : []), [multiple, value]);
150
+
151
+ function commit(next: Value, reason: AutocompleteChangeReason, event?: Event) {
152
+ setValue(next);
153
+ onChange?.(event, next, reason);
154
+ }
155
+
156
+ function handleSelectOption(option: Option, event?: Event) {
157
+ if (multiple) {
158
+ let alreadySelected = selectedOptions.some((item) => areOptionsEqual(item, option, isOptionEqualToValue));
159
+ if (alreadySelected) return;
160
+
161
+ commit([...selectedOptions, option] as Value, "selectOption", event);
162
+ setInputValue("");
163
+ onInputChange?.(event, "", "reset");
164
+ } else {
165
+ commit(option as unknown as Value, "selectOption", event);
166
+ }
167
+ }
168
+
169
+ function handleRemoveChip(option: Option) {
170
+ let next = selectedOptions.filter((item) => !areOptionsEqual(item, option, isOptionEqualToValue));
171
+ commit(next as Value, "removeOption");
172
+ }
173
+
174
+ function handleInputKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
175
+ if (event.key !== "Enter" || !freeSolo || highlightedRef.current !== undefined) return;
176
+
177
+ let text = inputValue.trim();
178
+ if (!text) return;
179
+
180
+ if (multiple) {
181
+ commit([...selectedOptions, text] as Value, "createOption", event.nativeEvent);
182
+ setInputValue("");
183
+ onInputChange?.(event.nativeEvent, "", "reset");
184
+ } else {
185
+ commit(text as unknown as Value, "createOption", event.nativeEvent);
186
+ }
187
+ }
188
+
189
+ function handleInputBlur(event: React.FocusEvent<HTMLInputElement>) {
190
+ if (freeSolo) return;
191
+
192
+ // Without free text, the input only makes sense as a reflection of the
193
+ // current selection, so anything the user typed is discarded.
194
+ let text = multiple || value == null ? "" : getOptionLabel(value as Option);
195
+ if (text !== inputValue) {
196
+ setInputValue(text);
197
+ onInputChange?.(event.nativeEvent, text, "reset");
198
+ }
199
+ }
200
+
201
+ let chips =
202
+ multiple && selectedOptions.length > 0
203
+ ? selectedOptions.map((option) => (
204
+ <ChipRemovable
205
+ key={getOptionKey?.(option) ?? getOptionLabel(option)}
206
+ data-size={size}
207
+ disabled={disabled || readOnly}
208
+ onClick={() => handleRemoveChip(option)}
209
+ >
210
+ {renderChipLabel?.(option) ?? getOptionLabel(option)}
211
+ </ChipRemovable>
212
+ ))
213
+ : undefined;
214
+
215
+ function renderItem(option: Option, index: number) {
216
+ let selected = multiple
217
+ ? selectedOptions.some((item) => areOptionsEqual(item, option, isOptionEqualToValue))
218
+ : value != null && areOptionsEqual(value as Option, option, isOptionEqualToValue);
219
+
220
+ return (
221
+ <BaseAutocomplete.Item
222
+ key={getOptionKey?.(option) ?? getOptionLabel(option)}
223
+ className="ObAutocomplete-item"
224
+ value={option}
225
+ index={index}
226
+ disabled={getOptionDisabled?.(option)}
227
+ data-selected={selected || undefined}
228
+ onClick={(event) => handleSelectOption(option, event.nativeEvent)}
229
+ >
230
+ <span>{renderOption?.(option, { index, inputValue, selected }) ?? getOptionLabel(option)}</span>
231
+ </BaseAutocomplete.Item>
232
+ );
233
+ }
234
+
235
+ function renderListItem(item: any, index: number) {
236
+ if (!groupBy) return renderItem(item as Option, index);
237
+
238
+ return (
239
+ <BaseAutocomplete.Group key={item.value} items={item.items}>
240
+ <BaseAutocomplete.GroupLabel className="ObAutocomplete-groupLabel">{item.value}</BaseAutocomplete.GroupLabel>
241
+ <BaseAutocomplete.Collection>
242
+ {(option: Option, optionIndex: number) => renderItem(option, optionIndex)}
243
+ </BaseAutocomplete.Collection>
244
+ </BaseAutocomplete.Group>
245
+ );
246
+ }
247
+
248
+ return (
249
+ <BaseAutocomplete.Root
250
+ items={options as any}
251
+ filteredItems={filteredItems as any}
252
+ filter={null}
253
+ value={inputValue}
254
+ onValueChange={(next, details) => {
255
+ handleInputValueChange(next, details, setInputValue, onInputChange);
256
+
257
+ if (details.reason === "clear-press" || details.reason === "input-clear") {
258
+ commit(emptyValue, "clear", details.event);
259
+ }
260
+ }}
261
+ itemToStringValue={getOptionLabel as (option: unknown) => string}
262
+ onItemHighlighted={(item) => {
263
+ highlightedRef.current = item as Option | undefined;
264
+ }}
265
+ disabled={disabled}
266
+ readOnly={readOnly}
267
+ required={required}
268
+ name={name}
269
+ id={id}
270
+ open={open}
271
+ defaultOpen={defaultOpen}
272
+ onOpenChange={(nextOpen) => onOpenChange?.(nextOpen)}
273
+ openOnInputClick={openOnFocus}
274
+ autoHighlight={autoHighlight}
275
+ limit={limit}
276
+ modal={modal}
277
+ >
278
+ <AutocompleteFrame
279
+ id={id}
280
+ label={props.label}
281
+ description={props.description}
282
+ error={props.error}
283
+ className={className}
284
+ style={style}
285
+ controlClassName={controlClassName}
286
+ popupClassName={popupClassName}
287
+ fullWidth={fullWidth}
288
+ size={size}
289
+ color={color}
290
+ disabled={disabled}
291
+ readOnly={readOnly}
292
+ required={required}
293
+ placeholder={placeholder}
294
+ loading={loading}
295
+ disableClearable={disableClearable}
296
+ inputRef={inputRef}
297
+ chips={chips}
298
+ trigger={
299
+ <BaseAutocomplete.Trigger
300
+ className="ObAutocomplete-button ObAutocomplete-trigger"
301
+ disabled={disabled || readOnly}
302
+ aria-label={getLocalizedString("Open")}
303
+ >
304
+ <ChevronDownIcon aria-hidden fontSize="1.25em" />
305
+ </BaseAutocomplete.Trigger>
306
+ }
307
+ emptyContent={
308
+ loading ? (loadingText ?? getLocalizedString("Loading")) : (noOptionsText ?? getLocalizedString("No options"))
309
+ }
310
+ renderListItem={renderListItem}
311
+ onInputKeyDown={handleInputKeyDown}
312
+ onInputBlur={handleInputBlur}
313
+ />
314
+ </BaseAutocomplete.Root>
315
+ );
316
+ }
@@ -0,0 +1,149 @@
1
+ import { Combobox as BaseCombobox } from "@base-ui/react/combobox";
2
+ import { FieldDescription, Label, Spinner, ValidationMessage } from "@digdir/designsystemet-react";
3
+ import { getLocalizedString } from "@olenbetong/appframe-core";
4
+ import { XMarkIcon } from "@navikt/aksel-icons";
5
+ import clsx from "clsx";
6
+ import type { ReactNode, Ref } from "react";
7
+
8
+ import type { AutocompleteSize } from "./types.js";
9
+
10
+ export interface AutocompleteFrameProps {
11
+ id?: string;
12
+ label?: ReactNode;
13
+ description?: ReactNode;
14
+ error?: ReactNode;
15
+ className?: string;
16
+ style?: React.CSSProperties;
17
+ controlClassName?: string;
18
+ popupClassName?: string;
19
+ fullWidth?: boolean;
20
+ size?: AutocompleteSize;
21
+ color?: string;
22
+ disabled?: boolean;
23
+ readOnly?: boolean;
24
+ required?: boolean;
25
+ placeholder?: string;
26
+ loading?: boolean;
27
+ disableClearable?: boolean;
28
+ inputRef?: Ref<HTMLInputElement>;
29
+ /**
30
+ * Chips for the selected values, rendered above the input.
31
+ */
32
+ chips?: ReactNode;
33
+ /**
34
+ * The trigger button, which differs between `Autocomplete` and `Combobox`.
35
+ */
36
+ trigger?: ReactNode;
37
+ /**
38
+ * Rendered in the popup when the filtered list is empty.
39
+ */
40
+ emptyContent?: ReactNode;
41
+ /**
42
+ * Renders an entry of the filtered list. Receives either an option or, when
43
+ * `groupBy` is used, a group.
44
+ */
45
+ renderListItem: (item: any, index: number) => ReactNode;
46
+ onInputKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
47
+ onInputBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
48
+ }
49
+
50
+ /**
51
+ * Shared chrome for `Autocomplete` and `Combobox`: the Designsystemet field
52
+ * (label, description, validation message), the bordered control holding the
53
+ * chips and the input, and the popup with the option list.
54
+ *
55
+ * All the parts used here are shared between the Base UI `Autocomplete` and
56
+ * `Combobox` namespaces, so they can be imported from `combobox` regardless of
57
+ * which root the caller renders.
58
+ */
59
+ export function AutocompleteFrame({
60
+ id,
61
+ label,
62
+ description,
63
+ error,
64
+ className,
65
+ style,
66
+ controlClassName,
67
+ popupClassName,
68
+ fullWidth = true,
69
+ size,
70
+ color,
71
+ disabled,
72
+ readOnly,
73
+ required,
74
+ placeholder,
75
+ loading,
76
+ disableClearable,
77
+ inputRef,
78
+ chips,
79
+ trigger,
80
+ emptyContent,
81
+ renderListItem,
82
+ onInputKeyDown,
83
+ onInputBlur,
84
+ }: AutocompleteFrameProps) {
85
+ return (
86
+ // Designsystemet's `Field` is a custom element that expects to own the
87
+ // layout, so the root here is a plain grid instead.
88
+ <div
89
+ className={clsx("ObAutocomplete-root", className)}
90
+ style={style}
91
+ data-size={size}
92
+ data-color={color}
93
+ data-full-width={fullWidth}
94
+ >
95
+ {label && (
96
+ <Label htmlFor={id} data-size={size}>
97
+ {label}
98
+ {required && <span aria-hidden> *</span>}
99
+ </Label>
100
+ )}
101
+ {description && <FieldDescription data-size={size}>{description}</FieldDescription>}
102
+ <div
103
+ className={clsx("ObAutocomplete-control", controlClassName)}
104
+ data-invalid={error ? true : undefined}
105
+ data-disabled={disabled ? true : undefined}
106
+ >
107
+ {chips && <div className="ObAutocomplete-chips">{chips}</div>}
108
+ <BaseCombobox.InputGroup className="ObAutocomplete-inputGroup">
109
+ <BaseCombobox.Input
110
+ id={id}
111
+ ref={inputRef}
112
+ className="ObAutocomplete-input"
113
+ placeholder={placeholder}
114
+ disabled={disabled}
115
+ readOnly={readOnly}
116
+ required={required}
117
+ aria-invalid={error ? true : undefined}
118
+ onKeyDown={onInputKeyDown}
119
+ onBlur={onInputBlur}
120
+ />
121
+ {loading && (
122
+ <span className="ObAutocomplete-spinner">
123
+ <Spinner aria-label={getLocalizedString("Loading")} data-size="xs" />
124
+ </span>
125
+ )}
126
+ {!disableClearable && (
127
+ <BaseCombobox.Clear
128
+ className="ObAutocomplete-button ObAutocomplete-clear"
129
+ disabled={disabled || readOnly}
130
+ aria-label={getLocalizedString("Clear")}
131
+ >
132
+ <XMarkIcon aria-hidden fontSize="1.25em" />
133
+ </BaseCombobox.Clear>
134
+ )}
135
+ {trigger}
136
+ </BaseCombobox.InputGroup>
137
+ </div>
138
+ {error && <ValidationMessage data-size={size}>{error}</ValidationMessage>}
139
+ <BaseCombobox.Portal>
140
+ <BaseCombobox.Positioner className="ObAutocomplete-positioner" sideOffset={4}>
141
+ <BaseCombobox.Popup className={clsx("ObAutocomplete-popup", popupClassName)} data-size={size}>
142
+ <BaseCombobox.Empty className="ObAutocomplete-empty">{emptyContent}</BaseCombobox.Empty>
143
+ <BaseCombobox.List className="ObAutocomplete-list">{renderListItem}</BaseCombobox.List>
144
+ </BaseCombobox.Popup>
145
+ </BaseCombobox.Positioner>
146
+ </BaseCombobox.Portal>
147
+ </div>
148
+ );
149
+ }