panelui-native 0.36.0 → 0.40.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/README.md +13 -1
- package/lib/module/components/combobox/index.js +718 -0
- package/lib/module/components/combobox/index.js.map +1 -0
- package/lib/module/components/form/use-field.js +18 -0
- package/lib/module/components/form/use-field.js.map +1 -1
- package/lib/module/components/form/use-form.js +91 -8
- package/lib/module/components/form/use-form.js.map +1 -1
- package/lib/module/components/heatmap-chart/index.js +113 -6
- package/lib/module/components/heatmap-chart/index.js.map +1 -1
- package/lib/module/components/input/index.js +10 -3
- package/lib/module/components/input/index.js.map +1 -1
- package/lib/module/components/pagination/index.js +519 -0
- package/lib/module/components/pagination/index.js.map +1 -0
- package/lib/module/components/ring-chart/index.js +183 -45
- package/lib/module/components/ring-chart/index.js.map +1 -1
- package/lib/module/components/select/index.js +116 -15
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/steps/index.js +8 -6
- package/lib/module/components/steps/index.js.map +1 -1
- package/lib/module/components/table/index.js +100 -26
- package/lib/module/components/table/index.js.map +1 -1
- package/lib/module/components/timeline/index.js +8 -7
- package/lib/module/components/timeline/index.js.map +1 -1
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/theme/use-theme.js +1 -1
- package/lib/typescript/src/components/combobox/index.d.ts +164 -0
- package/lib/typescript/src/components/combobox/index.d.ts.map +1 -0
- package/lib/typescript/src/components/form/use-field.d.ts.map +1 -1
- package/lib/typescript/src/components/form/use-form.d.ts.map +1 -1
- package/lib/typescript/src/components/heatmap-chart/index.d.ts +51 -3
- package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/input/index.d.ts +18 -4
- package/lib/typescript/src/components/input/index.d.ts.map +1 -1
- package/lib/typescript/src/components/pagination/index.d.ts +285 -0
- package/lib/typescript/src/components/pagination/index.d.ts.map +1 -0
- package/lib/typescript/src/components/ring-chart/index.d.ts +75 -8
- package/lib/typescript/src/components/ring-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/select/index.d.ts +32 -0
- package/lib/typescript/src/components/select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/steps/index.d.ts +8 -6
- package/lib/typescript/src/components/steps/index.d.ts.map +1 -1
- package/lib/typescript/src/components/table/index.d.ts +59 -17
- package/lib/typescript/src/components/table/index.d.ts.map +1 -1
- package/lib/typescript/src/components/timeline/index.d.ts +8 -7
- package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +4 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/theme/use-theme.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/combobox/index.tsx +927 -0
- package/src/components/form/use-field.ts +23 -0
- package/src/components/form/use-form.ts +106 -16
- package/src/components/heatmap-chart/index.tsx +147 -5
- package/src/components/input/index.tsx +21 -7
- package/src/components/pagination/index.tsx +591 -0
- package/src/components/ring-chart/index.tsx +269 -47
- package/src/components/select/index.tsx +114 -15
- package/src/components/steps/index.tsx +8 -6
- package/src/components/table/index.tsx +146 -30
- package/src/components/timeline/index.tsx +8 -7
- package/src/index.ts +25 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Combobox — a text field that filters a list of options as you type.
|
|
5
|
+
*
|
|
6
|
+
* The difference from Select is where the typing happens, and it is not a
|
|
7
|
+
* detail: a Select is a button that opens a list, and its optional filter lives
|
|
8
|
+
* *inside* the list once it is open. A Combobox is the field itself. You are
|
|
9
|
+
* already typing when the options appear, which is what you want when the value
|
|
10
|
+
* is something you know the name of — a city, a repository, a tag — rather than
|
|
11
|
+
* something you expect to recognise by scrolling.
|
|
12
|
+
*
|
|
13
|
+
* ```tsx
|
|
14
|
+
* <Combobox value={framework} onValueChange={setFramework}>
|
|
15
|
+
* <Combobox.Item value="expo" label="Expo" />
|
|
16
|
+
* <Combobox.Item value="next" label="Next.js" />
|
|
17
|
+
* </Combobox>
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* ## Two presentations, and why there is no sheet
|
|
21
|
+
*
|
|
22
|
+
* `overlay` (default) floats the list above the page through a portal, anchored
|
|
23
|
+
* under the field and flipped above it when the keyboard leaves no room below.
|
|
24
|
+
* `inline` expands the list in normal layout flow instead, which is right in a
|
|
25
|
+
* form where nothing should be covered.
|
|
26
|
+
*
|
|
27
|
+
* There is deliberately no sheet presentation. A sheet takes the bottom of the
|
|
28
|
+
* screen, which is exactly where the keyboard is, and the field you are typing
|
|
29
|
+
* into would end up behind one or the other. Select can offer a sheet because
|
|
30
|
+
* its trigger stops mattering once the list is open; a Combobox's never does.
|
|
31
|
+
*
|
|
32
|
+
* ## Filtering is yours to turn off
|
|
33
|
+
*
|
|
34
|
+
* Filtering happens here by default, matching case-insensitively on any part of
|
|
35
|
+
* an option's label. That is the whole feature for a list you already have in
|
|
36
|
+
* hand. When the options come from a server that is doing the matching itself,
|
|
37
|
+
* pass `filter={false}` and render whatever came back — the field stops second-
|
|
38
|
+
* guessing results it cannot see the query behind.
|
|
39
|
+
*
|
|
40
|
+
* ## Values it does not know about
|
|
41
|
+
*
|
|
42
|
+
* `allowCustomValue` lets the typed text become the value when it matches no
|
|
43
|
+
* option, which is how a tag field works: the list is a set of suggestions
|
|
44
|
+
* rather than the set of legal answers.
|
|
45
|
+
*/
|
|
46
|
+
import { Children, cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
47
|
+
import { Pressable, ScrollView, TextInput, useWindowDimensions, View } from 'react-native';
|
|
48
|
+
import Animated, { FadeIn, FadeOut, interpolateColor, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
|
49
|
+
import { tv } from 'tailwind-variants';
|
|
50
|
+
import { useCSSVariable } from 'uniwind';
|
|
51
|
+
import { CheckIcon, ChevronDownIcon, XIcon } from "../../icons/index.js";
|
|
52
|
+
import { Portal } from "../../primitives/portal.js";
|
|
53
|
+
import { Text, textChildren } from "../../primitives/text.js";
|
|
54
|
+
import { useBackHandler } from "../../hooks/use-back-handler.js";
|
|
55
|
+
import { useKeyboard } from "../../hooks/use-keyboard.js";
|
|
56
|
+
import { cn } from "../../utils/cn.js";
|
|
57
|
+
import { Chip } from "../chip/index.js";
|
|
58
|
+
import { Spinner } from "../spinner/index.js";
|
|
59
|
+
|
|
60
|
+
/** Matches Input's focus crossfade, so the two read as the same control. */
|
|
61
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
62
|
+
const FOCUS_DURATION = 150;
|
|
63
|
+
const comboboxVariants = tv({
|
|
64
|
+
slots: {
|
|
65
|
+
root: 'w-full',
|
|
66
|
+
/*
|
|
67
|
+
* `rounded-lg` and the same padding scale as Select's trigger and Input's
|
|
68
|
+
* field: a Combobox sitting in a form beside either of them has to read as
|
|
69
|
+
* the same family of control, not as a text field that happens to be near
|
|
70
|
+
* a picker.
|
|
71
|
+
*
|
|
72
|
+
* The border colour is animated between the resting and focused tokens, so
|
|
73
|
+
* it is deliberately absent from the class.
|
|
74
|
+
*/
|
|
75
|
+
field: 'w-full flex-row items-center gap-2 rounded-lg border bg-background px-4 py-2.5',
|
|
76
|
+
// Chips wrap onto their own lines; the input keeps a sane minimum so it is
|
|
77
|
+
// still tappable once a few of them are in front of it.
|
|
78
|
+
fieldContent: 'flex-1 flex-row flex-wrap items-center gap-1.5 py-1',
|
|
79
|
+
input: 'min-w-24 flex-1 py-1 text-base font-normal text-foreground',
|
|
80
|
+
action: 'h-6 w-6 items-center justify-center rounded-full',
|
|
81
|
+
list: 'overflow-hidden rounded-xl border border-border bg-popover p-2 shadow-sm',
|
|
82
|
+
item: 'flex-row items-center gap-2 rounded-lg px-3 py-3',
|
|
83
|
+
itemLabel: 'flex-1 text-base font-medium text-foreground',
|
|
84
|
+
itemIndicator: 'h-5 w-5 items-center justify-center',
|
|
85
|
+
group: 'gap-1',
|
|
86
|
+
groupLabel: 'px-3 pb-1 pt-2',
|
|
87
|
+
status: 'flex-row items-center justify-center gap-2 px-3 py-6'
|
|
88
|
+
},
|
|
89
|
+
variants: {
|
|
90
|
+
selected: {
|
|
91
|
+
true: {
|
|
92
|
+
item: 'bg-accent'
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
disabled: {
|
|
96
|
+
true: {
|
|
97
|
+
field: 'opacity-[0.64]'
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
itemDisabled: {
|
|
101
|
+
true: {
|
|
102
|
+
item: 'opacity-[0.64]'
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
presentation: {
|
|
106
|
+
overlay: {
|
|
107
|
+
list: 'shadow-lg'
|
|
108
|
+
},
|
|
109
|
+
inline: {
|
|
110
|
+
list: 'mt-2'
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
defaultVariants: {
|
|
115
|
+
presentation: 'overlay'
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/** Which selection shape a `mode` produces. */
|
|
120
|
+
|
|
121
|
+
const ComboboxContext = /*#__PURE__*/createContext(null);
|
|
122
|
+
/** Declarative option. Rendered inside whichever surface is presenting. */
|
|
123
|
+
function ComboboxItem({
|
|
124
|
+
value,
|
|
125
|
+
label,
|
|
126
|
+
disabled,
|
|
127
|
+
start,
|
|
128
|
+
description
|
|
129
|
+
}) {
|
|
130
|
+
const context = useContext(ComboboxContext);
|
|
131
|
+
if (!context) {
|
|
132
|
+
throw new Error('Combobox.Item must be used within a <Combobox>');
|
|
133
|
+
}
|
|
134
|
+
const selected = context.values.includes(value);
|
|
135
|
+
const {
|
|
136
|
+
item,
|
|
137
|
+
itemLabel,
|
|
138
|
+
itemIndicator
|
|
139
|
+
} = comboboxVariants({
|
|
140
|
+
selected,
|
|
141
|
+
itemDisabled: !!disabled
|
|
142
|
+
});
|
|
143
|
+
const checkColor = useCSSVariable('--color-muted-foreground');
|
|
144
|
+
return /*#__PURE__*/_jsxs(Pressable, {
|
|
145
|
+
accessibilityRole: "menuitem",
|
|
146
|
+
accessibilityState: {
|
|
147
|
+
selected,
|
|
148
|
+
disabled: !!disabled
|
|
149
|
+
},
|
|
150
|
+
disabled: disabled,
|
|
151
|
+
onPress: () => context.onSelect(value, label),
|
|
152
|
+
className: item(),
|
|
153
|
+
children: [start, /*#__PURE__*/_jsxs(View, {
|
|
154
|
+
className: "flex-1",
|
|
155
|
+
children: [/*#__PURE__*/_jsx(Text, {
|
|
156
|
+
className: itemLabel(),
|
|
157
|
+
children: label
|
|
158
|
+
}), description ? /*#__PURE__*/_jsx(Text, {
|
|
159
|
+
size: "sm",
|
|
160
|
+
muted: true,
|
|
161
|
+
numberOfLines: 1,
|
|
162
|
+
children: description
|
|
163
|
+
}) : null]
|
|
164
|
+
}), /*#__PURE__*/_jsx(View, {
|
|
165
|
+
className: itemIndicator(),
|
|
166
|
+
children: selected ? /*#__PURE__*/_jsx(CheckIcon, {
|
|
167
|
+
size: 16,
|
|
168
|
+
color: typeof checkColor === 'string' ? checkColor : '#737373'
|
|
169
|
+
}) : null
|
|
170
|
+
})]
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* A titled run of options.
|
|
175
|
+
*
|
|
176
|
+
* Presentational only: a grouped Combobox reports the same values a flat one
|
|
177
|
+
* would, and `Combobox.Item` needs to know nothing about being inside one.
|
|
178
|
+
*/
|
|
179
|
+
function ComboboxGroup({
|
|
180
|
+
label,
|
|
181
|
+
className,
|
|
182
|
+
labelClassName,
|
|
183
|
+
children
|
|
184
|
+
}) {
|
|
185
|
+
const {
|
|
186
|
+
group,
|
|
187
|
+
groupLabel
|
|
188
|
+
} = comboboxVariants();
|
|
189
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
190
|
+
className: cn(group(), className),
|
|
191
|
+
children: [label ? /*#__PURE__*/_jsx(View, {
|
|
192
|
+
accessibilityRole: "header",
|
|
193
|
+
className: cn(groupLabel(), labelClassName),
|
|
194
|
+
children: /*#__PURE__*/_jsx(Text, {
|
|
195
|
+
size: "xs",
|
|
196
|
+
weight: "medium",
|
|
197
|
+
muted: true,
|
|
198
|
+
className: "uppercase tracking-wide",
|
|
199
|
+
children: label
|
|
200
|
+
})
|
|
201
|
+
}) : null, textChildren(children)]
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Walk the declared children, visiting every option — including the ones nested
|
|
207
|
+
* inside a `Combobox.Group`.
|
|
208
|
+
*
|
|
209
|
+
* The flat set is what the field's own text needs: the label to show for a
|
|
210
|
+
* selected value, and the chips to draw for several of them. Rendering keeps
|
|
211
|
+
* the tree; only the lookup is flattened.
|
|
212
|
+
*/
|
|
213
|
+
function eachOption(children, visit) {
|
|
214
|
+
Children.forEach(children, child => {
|
|
215
|
+
if (! /*#__PURE__*/isValidElement(child)) return;
|
|
216
|
+
if (child.type === ComboboxGroup) {
|
|
217
|
+
eachOption(child.props.children, visit);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (child.type !== ComboboxItem) return;
|
|
221
|
+
visit(child.props);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The children a query leaves standing.
|
|
227
|
+
*
|
|
228
|
+
* A group is rebuilt around whatever survives inside it and dropped when that
|
|
229
|
+
* is nothing — a heading over no options reads as a section that failed to load
|
|
230
|
+
* rather than one the query emptied.
|
|
231
|
+
*/
|
|
232
|
+
function filterOptions(children, matches) {
|
|
233
|
+
const kept = [];
|
|
234
|
+
Children.forEach(children, child => {
|
|
235
|
+
if (! /*#__PURE__*/isValidElement(child)) return;
|
|
236
|
+
if (child.type === ComboboxGroup) {
|
|
237
|
+
const props = child.props;
|
|
238
|
+
const inner = filterOptions(props.children, matches);
|
|
239
|
+
if (inner.length) {
|
|
240
|
+
kept.push(/*#__PURE__*/cloneElement(child, {}, inner));
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (child.type === ComboboxItem && matches(child.props)) {
|
|
245
|
+
kept.push(child);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
return kept;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Field frame in window coordinates, measured when the list opens. */
|
|
252
|
+
|
|
253
|
+
function ComboboxRoot({
|
|
254
|
+
className,
|
|
255
|
+
mode,
|
|
256
|
+
value,
|
|
257
|
+
defaultValue,
|
|
258
|
+
onValueChange,
|
|
259
|
+
inputValue,
|
|
260
|
+
defaultInputValue = '',
|
|
261
|
+
onInputValueChange,
|
|
262
|
+
placeholder = 'Search',
|
|
263
|
+
disabled = false,
|
|
264
|
+
presentation = 'overlay',
|
|
265
|
+
filter = true,
|
|
266
|
+
allowCustomValue = false,
|
|
267
|
+
loading = false,
|
|
268
|
+
emptyMessage = 'No matches',
|
|
269
|
+
loadingMessage = 'Searching',
|
|
270
|
+
clearable = false,
|
|
271
|
+
openOnFocus = false,
|
|
272
|
+
onOpenChange,
|
|
273
|
+
contentWidth = 'field',
|
|
274
|
+
offset = 8,
|
|
275
|
+
listClassName,
|
|
276
|
+
accessibilityLabel,
|
|
277
|
+
children,
|
|
278
|
+
...props
|
|
279
|
+
}) {
|
|
280
|
+
const multiple = mode === 'multiple';
|
|
281
|
+
const [open, setOpen] = useState(false);
|
|
282
|
+
const [focused, setFocused] = useState(false);
|
|
283
|
+
const [anchor, setAnchor] = useState(null);
|
|
284
|
+
const [listHeight, setListHeight] = useState(0);
|
|
285
|
+
/*
|
|
286
|
+
* The anchor is measured off the plain wrapper rather than off the animated
|
|
287
|
+
* field inside it. In `overlay` the wrapper *is* the field's box — the list
|
|
288
|
+
* is portalled out — and a host View is the thing with a dependable
|
|
289
|
+
* `measureInWindow`. `inline` never reads the anchor, so the list it also
|
|
290
|
+
* wraps cannot skew anything.
|
|
291
|
+
*/
|
|
292
|
+
const fieldRef = useRef(null);
|
|
293
|
+
const inputRef = useRef(null);
|
|
294
|
+
const {
|
|
295
|
+
height: screenHeight
|
|
296
|
+
} = useWindowDimensions();
|
|
297
|
+
/*
|
|
298
|
+
* The keyboard is up whenever this list is open — the field is a text input
|
|
299
|
+
* and opening the list is what typing in it does. So the space the list has
|
|
300
|
+
* to work with is never the window: it is the window above the keyboard, and
|
|
301
|
+
* measuring against the window would put the options behind it.
|
|
302
|
+
*/
|
|
303
|
+
const {
|
|
304
|
+
height: keyboardHeight
|
|
305
|
+
} = useKeyboard();
|
|
306
|
+
const [internalValue, setInternalValue] = useState(() => defaultValue ?? (mode === 'multiple' ? [] : undefined));
|
|
307
|
+
const selection = value !== undefined ? value : internalValue;
|
|
308
|
+
const [internalQuery, setInternalQuery] = useState(defaultInputValue);
|
|
309
|
+
const query = inputValue !== undefined ? inputValue : internalQuery;
|
|
310
|
+
|
|
311
|
+
/** The selection as a list, which is the shape everything downstream wants. */
|
|
312
|
+
const values = useMemo(() => {
|
|
313
|
+
if (selection == null) return [];
|
|
314
|
+
return Array.isArray(selection) ? selection : [selection];
|
|
315
|
+
}, [selection]);
|
|
316
|
+
const options = useMemo(() => {
|
|
317
|
+
const collected = [];
|
|
318
|
+
eachOption(children, option => collected.push(option));
|
|
319
|
+
return collected;
|
|
320
|
+
}, [children]);
|
|
321
|
+
const labelOf = useCallback(candidate => options.find(option => option.value === candidate)?.label ?? candidate, [options]);
|
|
322
|
+
const setQuery = useCallback(next => {
|
|
323
|
+
if (inputValue === undefined) setInternalQuery(next);
|
|
324
|
+
onInputValueChange?.(next);
|
|
325
|
+
}, [inputValue, onInputValueChange]);
|
|
326
|
+
const commit = useCallback(next => {
|
|
327
|
+
if (value === undefined) setInternalValue(next);
|
|
328
|
+
onValueChange?.(next);
|
|
329
|
+
}, [value, onValueChange]);
|
|
330
|
+
const setOpenState = useCallback(next => {
|
|
331
|
+
setOpen(current => {
|
|
332
|
+
if (current === next) return current;
|
|
333
|
+
onOpenChange?.(next);
|
|
334
|
+
return next;
|
|
335
|
+
});
|
|
336
|
+
}, [onOpenChange]);
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The floating list is positioned in window coordinates, so it has to know
|
|
340
|
+
* where the field actually landed — not where layout said it would.
|
|
341
|
+
*/
|
|
342
|
+
const openList = useCallback(() => {
|
|
343
|
+
if (disabled) return;
|
|
344
|
+
if (presentation !== 'overlay') {
|
|
345
|
+
setOpenState(true);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
fieldRef.current?.measureInWindow((x, y, width, height) => {
|
|
349
|
+
setAnchor({
|
|
350
|
+
x,
|
|
351
|
+
y,
|
|
352
|
+
width,
|
|
353
|
+
height
|
|
354
|
+
});
|
|
355
|
+
setOpenState(true);
|
|
356
|
+
});
|
|
357
|
+
}, [disabled, presentation, setOpenState]);
|
|
358
|
+
const close = useCallback(() => setOpenState(false), [setOpenState]);
|
|
359
|
+
|
|
360
|
+
// An open overlay list catches the Android back button, closing itself
|
|
361
|
+
// instead of popping the screen behind it.
|
|
362
|
+
useBackHandler(open && presentation === 'overlay', close);
|
|
363
|
+
|
|
364
|
+
/*
|
|
365
|
+
* The anchor is a snapshot, and the keyboard invalidates it: a scroll view
|
|
366
|
+
* that lifts its content clear of the keyboard moves the field after it was
|
|
367
|
+
* measured, and the list would stay at the old position. Re-measure whenever
|
|
368
|
+
* the keyboard's height changes while the list is open.
|
|
369
|
+
*/
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
if (!open || presentation !== 'overlay') return;
|
|
372
|
+
fieldRef.current?.measureInWindow((x, y, width, height) => setAnchor({
|
|
373
|
+
x,
|
|
374
|
+
y,
|
|
375
|
+
width,
|
|
376
|
+
height
|
|
377
|
+
}));
|
|
378
|
+
}, [open, presentation, keyboardHeight]);
|
|
379
|
+
|
|
380
|
+
/*
|
|
381
|
+
* A single-select field shows the chosen option's label when it is not being
|
|
382
|
+
* typed into. Re-deriving it on every keystroke would fight the typing, so it
|
|
383
|
+
* is only written back when the selection itself changes and the field is not
|
|
384
|
+
* focused — which covers a value arriving from outside, and the blur after a
|
|
385
|
+
* pick. A custom-value field is left alone: the text *is* the value there.
|
|
386
|
+
*/
|
|
387
|
+
const singleValue = multiple ? undefined : selection;
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
if (multiple || focused || allowCustomValue) return;
|
|
390
|
+
setQuery(singleValue == null ? '' : labelOf(singleValue));
|
|
391
|
+
// `setQuery` is stable per controlled-ness; re-running on every identity
|
|
392
|
+
// change would overwrite the query the caller is controlling.
|
|
393
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
394
|
+
}, [multiple, focused, allowCustomValue, singleValue, labelOf]);
|
|
395
|
+
const matcher = useCallback(option => {
|
|
396
|
+
if (filter === false) return true;
|
|
397
|
+
const needle = query.trim().toLowerCase();
|
|
398
|
+
if (!needle) return true;
|
|
399
|
+
if (typeof filter === 'function') return filter(option, query.trim());
|
|
400
|
+
return option.label.toLowerCase().includes(needle);
|
|
401
|
+
}, [filter, query]);
|
|
402
|
+
|
|
403
|
+
/*
|
|
404
|
+
* `null` means "render the children as given" — nothing is being narrowed, so
|
|
405
|
+
* an unfiltered list does no per-option work at all.
|
|
406
|
+
*/
|
|
407
|
+
const filtered = useMemo(() => {
|
|
408
|
+
if (filter === false) return null;
|
|
409
|
+
if (!query.trim()) return null;
|
|
410
|
+
return filterOptions(children, matcher);
|
|
411
|
+
}, [children, filter, query, matcher]);
|
|
412
|
+
const exactMatch = useMemo(() => options.some(option => option.label.toLowerCase() === query.trim().toLowerCase()), [options, query]);
|
|
413
|
+
const select = useCallback(next => {
|
|
414
|
+
if (multiple) {
|
|
415
|
+
const current = Array.isArray(selection) ? selection : [];
|
|
416
|
+
const without = current.filter(entry => entry !== next);
|
|
417
|
+
// Toggling: picking a chosen option again removes it, which is the only
|
|
418
|
+
// way to undo a pick without reaching for its chip.
|
|
419
|
+
const updated = without.length === current.length ? [...current, next] : without;
|
|
420
|
+
commit(updated);
|
|
421
|
+
// The query has done its job once the pick is made, and leaving it
|
|
422
|
+
// would hide every option that does not also match it.
|
|
423
|
+
setQuery('');
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
commit(next);
|
|
427
|
+
setQuery(labelOf(next));
|
|
428
|
+
close();
|
|
429
|
+
inputRef.current?.blur();
|
|
430
|
+
}, [multiple, selection, commit, setQuery, labelOf, close]);
|
|
431
|
+
|
|
432
|
+
/** Enter, or the keyboard's Done: take the typed text if it can be taken. */
|
|
433
|
+
const submit = useCallback(() => {
|
|
434
|
+
const typed = query.trim();
|
|
435
|
+
if (!typed) return;
|
|
436
|
+
const match = options.find(option => option.label.toLowerCase() === typed.toLowerCase());
|
|
437
|
+
if (match && !match.disabled) {
|
|
438
|
+
select(match.value);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (!allowCustomValue) return;
|
|
442
|
+
if (multiple) {
|
|
443
|
+
const current = Array.isArray(selection) ? selection : [];
|
|
444
|
+
if (!current.includes(typed)) {
|
|
445
|
+
commit([...current, typed]);
|
|
446
|
+
}
|
|
447
|
+
setQuery('');
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
commit(typed);
|
|
451
|
+
close();
|
|
452
|
+
}, [query, options, allowCustomValue, multiple, selection, select, commit, setQuery, close]);
|
|
453
|
+
const remove = useCallback(target => {
|
|
454
|
+
const current = Array.isArray(selection) ? selection : [];
|
|
455
|
+
commit(current.filter(entry => entry !== target));
|
|
456
|
+
}, [selection, commit]);
|
|
457
|
+
const clear = useCallback(() => {
|
|
458
|
+
setQuery('');
|
|
459
|
+
commit(multiple ? [] : undefined);
|
|
460
|
+
inputRef.current?.focus();
|
|
461
|
+
}, [setQuery, commit, multiple]);
|
|
462
|
+
const context = useMemo(() => ({
|
|
463
|
+
values,
|
|
464
|
+
onSelect: next => select(next)
|
|
465
|
+
}), [values, select]);
|
|
466
|
+
const slots = comboboxVariants({
|
|
467
|
+
disabled,
|
|
468
|
+
presentation
|
|
469
|
+
});
|
|
470
|
+
const mutedColor = useCSSVariable('--color-muted-foreground');
|
|
471
|
+
const restColor = useCSSVariable('--color-input');
|
|
472
|
+
const focusColor = useCSSVariable('--color-ring');
|
|
473
|
+
const placeholderColor = typeof mutedColor === 'string' ? mutedColor : '#737373';
|
|
474
|
+
const focus = useSharedValue(0);
|
|
475
|
+
useEffect(() => {
|
|
476
|
+
focus.value = withTiming(focused ? 1 : 0, {
|
|
477
|
+
duration: FOCUS_DURATION
|
|
478
|
+
});
|
|
479
|
+
}, [focused, focus]);
|
|
480
|
+
const fieldStyle = useAnimatedStyle(() => {
|
|
481
|
+
const idle = typeof restColor === 'string' ? restColor : 'rgba(0,0,0,0.1)';
|
|
482
|
+
const active = typeof focusColor === 'string' ? focusColor : '#a3a3a3';
|
|
483
|
+
return {
|
|
484
|
+
borderColor: interpolateColor(focus.value, [0, 1], [idle, active])
|
|
485
|
+
};
|
|
486
|
+
});
|
|
487
|
+
const chevron = useSharedValue(0);
|
|
488
|
+
useEffect(() => {
|
|
489
|
+
chevron.value = withTiming(open ? 1 : 0, {
|
|
490
|
+
duration: 160
|
|
491
|
+
});
|
|
492
|
+
}, [open, chevron]);
|
|
493
|
+
const chevronStyle = useAnimatedStyle(() => ({
|
|
494
|
+
transform: [{
|
|
495
|
+
rotate: `${chevron.value * 180}deg`
|
|
496
|
+
}]
|
|
497
|
+
}));
|
|
498
|
+
const hasContent = query.length > 0 || values.length > 0;
|
|
499
|
+
|
|
500
|
+
/*
|
|
501
|
+
* The list body, built once and handed to whichever surface is presenting.
|
|
502
|
+
* The two differ in where they put it, not in what it is.
|
|
503
|
+
*/
|
|
504
|
+
/*
|
|
505
|
+
* `filtered === null` means nothing was narrowed — either there is no query
|
|
506
|
+
* or a server is doing the matching — so the children are rendered as given
|
|
507
|
+
* and emptiness is a question about the options themselves. A server that
|
|
508
|
+
* came back with nothing still has to say so, which is why this is not just
|
|
509
|
+
* `filtered.length`.
|
|
510
|
+
*/
|
|
511
|
+
const shown = filtered === null ? textChildren(children) : filtered;
|
|
512
|
+
const isEmpty = filtered === null ? options.length === 0 : filtered.length === 0;
|
|
513
|
+
const body = loading ? /*#__PURE__*/_jsxs(View, {
|
|
514
|
+
className: slots.status(),
|
|
515
|
+
children: [/*#__PURE__*/_jsx(Spinner, {
|
|
516
|
+
size: "sm"
|
|
517
|
+
}), /*#__PURE__*/_jsx(Text, {
|
|
518
|
+
size: "sm",
|
|
519
|
+
muted: true,
|
|
520
|
+
children: loadingMessage
|
|
521
|
+
})]
|
|
522
|
+
}) : isEmpty ? /*#__PURE__*/_jsx(Text, {
|
|
523
|
+
className: "px-3 py-6 text-center text-sm text-muted-foreground",
|
|
524
|
+
children: allowCustomValue && query.trim() && !exactMatch ? `Press return to add “${query.trim()}”` : emptyMessage
|
|
525
|
+
}) : shown;
|
|
526
|
+
const list = /*#__PURE__*/_jsx(ScrollView, {
|
|
527
|
+
bounces: false,
|
|
528
|
+
showsVerticalScrollIndicator: false
|
|
529
|
+
// The field above the list is a text input: without this a tap on an
|
|
530
|
+
// option would be swallowed by the keyboard dismissing first.
|
|
531
|
+
,
|
|
532
|
+
keyboardShouldPersistTaps: "always",
|
|
533
|
+
keyboardDismissMode: "on-drag",
|
|
534
|
+
children: /*#__PURE__*/_jsx(View, {
|
|
535
|
+
className: "gap-1",
|
|
536
|
+
children: body
|
|
537
|
+
})
|
|
538
|
+
});
|
|
539
|
+
const field = /*#__PURE__*/_jsxs(Animated.View, {
|
|
540
|
+
style: fieldStyle,
|
|
541
|
+
className: slots.field()
|
|
542
|
+
// The field is one control made of several views. Announcing it as a
|
|
543
|
+
// combobox that owns an expandable list is what makes the chips and the
|
|
544
|
+
// input read as parts of it rather than as loose siblings.
|
|
545
|
+
,
|
|
546
|
+
accessibilityRole: "combobox",
|
|
547
|
+
accessibilityLabel: accessibilityLabel,
|
|
548
|
+
accessibilityState: {
|
|
549
|
+
disabled,
|
|
550
|
+
expanded: open
|
|
551
|
+
},
|
|
552
|
+
children: [/*#__PURE__*/_jsxs(View, {
|
|
553
|
+
className: slots.fieldContent(),
|
|
554
|
+
children: [multiple ? values.map(entry => /*#__PURE__*/_jsx(Chip, {
|
|
555
|
+
size: "sm",
|
|
556
|
+
onClose: disabled ? undefined : () => remove(entry),
|
|
557
|
+
closeLabel: `Remove ${labelOf(entry)}`,
|
|
558
|
+
children: labelOf(entry)
|
|
559
|
+
}, entry)) : null, /*#__PURE__*/_jsx(TextInput, {
|
|
560
|
+
ref: inputRef,
|
|
561
|
+
className: slots.input(),
|
|
562
|
+
value: query,
|
|
563
|
+
onChangeText: next => {
|
|
564
|
+
setQuery(next);
|
|
565
|
+
if (!open) openList();
|
|
566
|
+
},
|
|
567
|
+
onFocus: () => {
|
|
568
|
+
setFocused(true);
|
|
569
|
+
if (openOnFocus) openList();
|
|
570
|
+
},
|
|
571
|
+
onBlur: () => setFocused(false),
|
|
572
|
+
onSubmitEditing: submit,
|
|
573
|
+
onKeyPress: ({
|
|
574
|
+
nativeEvent
|
|
575
|
+
}) => {
|
|
576
|
+
// Backspace on an empty field takes the last chip back — the same
|
|
577
|
+
// reflex that deletes a character, extended to the thing in front
|
|
578
|
+
// of the cursor when there is no character left to delete.
|
|
579
|
+
if (multiple && nativeEvent.key === 'Backspace' && query.length === 0 && values.length > 0) {
|
|
580
|
+
remove(values[values.length - 1]);
|
|
581
|
+
}
|
|
582
|
+
},
|
|
583
|
+
editable: !disabled,
|
|
584
|
+
placeholder: values.length && multiple ? undefined : placeholder,
|
|
585
|
+
placeholderTextColor: placeholderColor,
|
|
586
|
+
autoCapitalize: "none",
|
|
587
|
+
autoCorrect: false,
|
|
588
|
+
autoComplete: "off",
|
|
589
|
+
returnKeyType: allowCustomValue ? 'done' : 'search',
|
|
590
|
+
submitBehavior: multiple ? 'submit' : 'blurAndSubmit',
|
|
591
|
+
accessibilityLabel: accessibilityLabel ?? placeholder
|
|
592
|
+
})]
|
|
593
|
+
}), clearable && hasContent && !disabled ? /*#__PURE__*/_jsx(Pressable, {
|
|
594
|
+
accessibilityRole: "button",
|
|
595
|
+
accessibilityLabel: "Clear",
|
|
596
|
+
onPress: clear,
|
|
597
|
+
className: slots.action(),
|
|
598
|
+
children: /*#__PURE__*/_jsx(XIcon, {
|
|
599
|
+
size: 16,
|
|
600
|
+
color: placeholderColor
|
|
601
|
+
})
|
|
602
|
+
}) : null, /*#__PURE__*/_jsx(Pressable, {
|
|
603
|
+
accessibilityRole: "button",
|
|
604
|
+
accessibilityLabel: open ? 'Hide options' : 'Show options',
|
|
605
|
+
accessibilityState: {
|
|
606
|
+
expanded: open
|
|
607
|
+
},
|
|
608
|
+
disabled: disabled,
|
|
609
|
+
onPress: () => {
|
|
610
|
+
if (open) {
|
|
611
|
+
close();
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
openList();
|
|
615
|
+
inputRef.current?.focus();
|
|
616
|
+
},
|
|
617
|
+
className: slots.action(),
|
|
618
|
+
children: /*#__PURE__*/_jsx(Animated.View, {
|
|
619
|
+
style: chevronStyle,
|
|
620
|
+
children: /*#__PURE__*/_jsx(ChevronDownIcon, {
|
|
621
|
+
color: placeholderColor
|
|
622
|
+
})
|
|
623
|
+
})
|
|
624
|
+
})]
|
|
625
|
+
});
|
|
626
|
+
if (presentation === 'inline') {
|
|
627
|
+
return /*#__PURE__*/_jsx(ComboboxContext.Provider, {
|
|
628
|
+
value: context,
|
|
629
|
+
children: /*#__PURE__*/_jsxs(View, {
|
|
630
|
+
ref: fieldRef,
|
|
631
|
+
className: cn(slots.root(), className),
|
|
632
|
+
...props,
|
|
633
|
+
children: [field, open ? /*#__PURE__*/_jsx(Animated.View, {
|
|
634
|
+
entering: FadeIn.duration(140),
|
|
635
|
+
exiting: FadeOut.duration(120),
|
|
636
|
+
className: cn(slots.list(), 'max-h-80', listClassName),
|
|
637
|
+
children: list
|
|
638
|
+
}) : null]
|
|
639
|
+
})
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Flip above the field when the list would run off the bottom — where the
|
|
644
|
+
// bottom is the top of the keyboard, not the bottom of the screen. listHeight
|
|
645
|
+
// is 0 on the first frame, so it opens downwards and corrects itself once
|
|
646
|
+
// measured — invisible inside the 140ms fade.
|
|
647
|
+
const viewportBottom = screenHeight - keyboardHeight;
|
|
648
|
+
const spaceBelow = anchor ? viewportBottom - (anchor.y + anchor.height) - offset : 0;
|
|
649
|
+
const flip = !!anchor && listHeight > 0 && listHeight > spaceBelow;
|
|
650
|
+
const overlayPosition = anchor ? {
|
|
651
|
+
position: 'absolute',
|
|
652
|
+
left: anchor.x,
|
|
653
|
+
...(flip ? {
|
|
654
|
+
bottom: screenHeight - anchor.y + offset
|
|
655
|
+
} : {
|
|
656
|
+
top: anchor.y + anchor.height + offset
|
|
657
|
+
}),
|
|
658
|
+
...(contentWidth === 'field' ? {
|
|
659
|
+
width: anchor.width
|
|
660
|
+
} : typeof contentWidth === 'number' ? {
|
|
661
|
+
width: contentWidth
|
|
662
|
+
} : {
|
|
663
|
+
minWidth: anchor.width
|
|
664
|
+
}),
|
|
665
|
+
// Never collapse to nothing in a cramped viewport — the list scrolls.
|
|
666
|
+
maxHeight: Math.max((flip ? anchor.y : spaceBelow) - offset, 160)
|
|
667
|
+
} : null;
|
|
668
|
+
return /*#__PURE__*/_jsxs(ComboboxContext.Provider, {
|
|
669
|
+
value: context,
|
|
670
|
+
children: [/*#__PURE__*/_jsx(View, {
|
|
671
|
+
ref: fieldRef,
|
|
672
|
+
className: cn(slots.root(), className),
|
|
673
|
+
...props,
|
|
674
|
+
children: field
|
|
675
|
+
}), open && overlayPosition ? /*#__PURE__*/_jsxs(Portal, {
|
|
676
|
+
children: [/*#__PURE__*/_jsx(Pressable, {
|
|
677
|
+
accessible: false,
|
|
678
|
+
importantForAccessibility: "no-hide-descendants",
|
|
679
|
+
accessibilityElementsHidden: true,
|
|
680
|
+
onPress: () => {
|
|
681
|
+
close();
|
|
682
|
+
inputRef.current?.blur();
|
|
683
|
+
},
|
|
684
|
+
style: {
|
|
685
|
+
position: 'absolute',
|
|
686
|
+
top: 0,
|
|
687
|
+
left: 0,
|
|
688
|
+
right: 0,
|
|
689
|
+
bottom: 0
|
|
690
|
+
}
|
|
691
|
+
}), /*#__PURE__*/_jsx(ComboboxContext.Provider, {
|
|
692
|
+
value: context,
|
|
693
|
+
children: /*#__PURE__*/_jsx(Animated.View, {
|
|
694
|
+
entering: FadeIn.duration(140),
|
|
695
|
+
exiting: FadeOut.duration(120),
|
|
696
|
+
onLayout: event => setListHeight(event.nativeEvent.layout.height),
|
|
697
|
+
style: overlayPosition
|
|
698
|
+
/*
|
|
699
|
+
* The floating list covers the screen with a catcher and takes
|
|
700
|
+
* the back button, so it is a modal layer. Without this the page
|
|
701
|
+
* behind it stays in the accessibility tree and a screen reader
|
|
702
|
+
* could walk out of the open list into content it is covering.
|
|
703
|
+
*/,
|
|
704
|
+
accessibilityViewIsModal: true,
|
|
705
|
+
className: cn(slots.list(), listClassName),
|
|
706
|
+
children: list
|
|
707
|
+
})
|
|
708
|
+
})]
|
|
709
|
+
}) : null]
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
ComboboxItem.displayName = 'Combobox.Item';
|
|
713
|
+
ComboboxGroup.displayName = 'Combobox.Group';
|
|
714
|
+
export const Combobox = Object.assign(ComboboxRoot, {
|
|
715
|
+
Item: ComboboxItem,
|
|
716
|
+
Group: ComboboxGroup
|
|
717
|
+
});
|
|
718
|
+
//# sourceMappingURL=index.js.map
|