@allxsmith/bestax-bulma 5.37.0 → 5.38.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/dist/bestax.css +1 -1
- package/dist/bestax.css.map +1 -1
- package/dist/esm/components/Card.js +2 -2
- package/dist/esm/components/Carousel.js +14 -6
- package/dist/esm/components/Carousel.js.map +1 -1
- package/dist/esm/components/Dropdown.js +7 -2
- package/dist/esm/components/Dropdown.js.map +1 -1
- package/dist/esm/components/Loading.js +7 -1
- package/dist/esm/components/Loading.js.map +1 -1
- package/dist/esm/components/Panel.js +34 -4
- package/dist/esm/components/Panel.js.map +1 -1
- package/dist/esm/components/Popover.js +7 -2
- package/dist/esm/components/Popover.js.map +1 -1
- package/dist/esm/components/Sidebar.js +46 -17
- package/dist/esm/components/Sidebar.js.map +1 -1
- package/dist/esm/form/Autocomplete.js +28 -18
- package/dist/esm/form/Autocomplete.js.map +1 -1
- package/dist/esm/form/Combobox.js +592 -0
- package/dist/esm/form/Combobox.js.map +1 -0
- package/dist/esm/form/Field.js +3 -3
- package/dist/esm/form/Field.js.map +1 -1
- package/dist/esm/form/_pickerInternals/PickerPopover.js +10 -5
- package/dist/esm/form/_pickerInternals/PickerPopover.js.map +1 -1
- package/dist/esm/form/useAutoLabelId.js.map +1 -1
- package/dist/esm/helpers/Theme.js.map +1 -1
- package/dist/esm/helpers/useAnchoredPosition.js +35 -6
- package/dist/esm/helpers/useAnchoredPosition.js.map +1 -1
- package/dist/esm/helpers/useComboboxEngine.js +331 -0
- package/dist/esm/helpers/useComboboxEngine.js.map +1 -0
- package/dist/esm/helpers/useFocusTrap.js +87 -49
- package/dist/esm/helpers/useFocusTrap.js.map +1 -1
- package/dist/esm/helpers/useInertSiblings.js +106 -0
- package/dist/esm/helpers/useInertSiblings.js.map +1 -0
- package/dist/esm/helpers/useTreeView.js +2 -2
- package/dist/esm/layout/Container.js +2 -2
- package/dist/esm/layout/Container.js.map +1 -1
- package/dist/esm/layout/Hero.js +6 -6
- package/dist/esm/layout/Hero.js.map +1 -1
- package/dist/extras.css +1 -1
- package/dist/extras.css.map +1 -1
- package/dist/index.cjs +1504 -351
- package/dist/index.cjs.map +1 -1
- package/dist/index.esm.js +1 -0
- package/dist/index.esm.js.map +1 -1
- package/dist/types/components/Loading.d.ts +16 -2
- package/dist/types/components/Panel.d.ts +45 -5
- package/dist/types/components/Sidebar.d.ts +17 -2
- package/dist/types/form/Combobox.d.ts +416 -0
- package/dist/types/form/Field.d.ts +5 -5
- package/dist/types/form/useAutoLabelId.d.ts +3 -2
- package/dist/types/helpers/Theme.d.ts +6 -0
- package/dist/types/helpers/useAnchoredPosition.d.ts +25 -2
- package/dist/types/helpers/useComboboxEngine.d.ts +111 -0
- package/dist/types/helpers/useFocusTrap.d.ts +15 -2
- package/dist/types/helpers/useInertSiblings.d.ts +30 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/layout/Container.d.ts +9 -1
- package/dist/types/layout/Hero.d.ts +27 -2
- package/dist/types-cjs/components/Loading.d.ts +16 -2
- package/dist/types-cjs/components/Panel.d.ts +45 -5
- package/dist/types-cjs/components/Sidebar.d.ts +17 -2
- package/dist/types-cjs/form/Combobox.d.ts +416 -0
- package/dist/types-cjs/form/Field.d.ts +5 -5
- package/dist/types-cjs/form/useAutoLabelId.d.ts +3 -2
- package/dist/types-cjs/helpers/Theme.d.ts +6 -0
- package/dist/types-cjs/helpers/useAnchoredPosition.d.ts +25 -2
- package/dist/types-cjs/helpers/useComboboxEngine.d.ts +111 -0
- package/dist/types-cjs/helpers/useFocusTrap.d.ts +15 -2
- package/dist/types-cjs/helpers/useInertSiblings.d.ts +30 -0
- package/dist/types-cjs/index.d.ts +1 -0
- package/dist/types-cjs/layout/Container.d.ts +9 -1
- package/dist/types-cjs/layout/Hero.d.ts +27 -2
- package/dist/versions/bestax-no-dark-mode.css +1 -1
- package/dist/versions/bestax-no-dark-mode.css.map +1 -1
- package/dist/versions/bestax-no-helpers-prefixed.css +1 -1
- package/dist/versions/bestax-no-helpers-prefixed.css.map +1 -1
- package/dist/versions/bestax-no-helpers.css +1 -1
- package/dist/versions/bestax-no-helpers.css.map +1 -1
- package/dist/versions/bestax-prefixed.css +1 -1
- package/dist/versions/bestax-prefixed.css.map +1 -1
- package/package.json +1 -1
- package/src/scss/form/_combobox.scss +323 -0
- package/src/scss/form/_index.scss +1 -0
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
/* @allxsmith/bestax-bulma — AI agents: see AGENTS.md in the package root, or https://bestax.io/llms.txt */
|
|
2
|
+
import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
|
|
3
|
+
import { createContext, forwardRef, useState, useRef, useCallback, useId, useContext } from 'react';
|
|
4
|
+
import { usePrefixedClassNames, classNames } from '../helpers/classNames.js';
|
|
5
|
+
import { useBulmaClasses } from '../helpers/useBulmaClasses.js';
|
|
6
|
+
import { withSubComponents } from '../helpers/withSubComponents.js';
|
|
7
|
+
import { Portal } from '../helpers/portal.js';
|
|
8
|
+
import { useIsHydrated } from '../helpers/useIsHydrated.js';
|
|
9
|
+
import { useClientLayoutEffect } from '../helpers/useClientLayoutEffect.js';
|
|
10
|
+
import { flattenChildren } from '../helpers/flattenChildren.js';
|
|
11
|
+
import { warnOnce } from '../helpers/devWarnings.js';
|
|
12
|
+
import { useAnchoredPosition, marginGap } from '../helpers/useAnchoredPosition.js';
|
|
13
|
+
import { spokenText, StatusRegion } from '../helpers/statusRegion.js';
|
|
14
|
+
import { useComboboxEngine } from '../helpers/useComboboxEngine.js';
|
|
15
|
+
import { Delete } from '../elements/Delete.js';
|
|
16
|
+
import { Tag } from '../elements/Tag.js';
|
|
17
|
+
import { Tags } from '../elements/Tags.js';
|
|
18
|
+
import { Field } from './Field.js';
|
|
19
|
+
import { Input } from './Input.js';
|
|
20
|
+
import { useInsideField, useInsideControl, rendersOwnField } from './FormContext.js';
|
|
21
|
+
import { useAutoLabelId } from './useAutoLabelId.js';
|
|
22
|
+
import { useControlLoading } from './controlLoading.js';
|
|
23
|
+
|
|
24
|
+
const validComboboxSizes = ['small', 'medium', 'large'];
|
|
25
|
+
// What the status region announces: the `Combobox.Empty` content, as the one
|
|
26
|
+
// item it's handed while that content is on screen because of the user's
|
|
27
|
+
// typing.
|
|
28
|
+
const typedNoMatches = [{ id: 'empty' }];
|
|
29
|
+
const nothingToAnnounce = [];
|
|
30
|
+
const noop = () => { };
|
|
31
|
+
const registerNothing = () => noop;
|
|
32
|
+
// What a part rendered outside a Combobox reads: closed, with nothing to
|
|
33
|
+
// report to, so it renders its markup and does nothing.
|
|
34
|
+
const standalone = {
|
|
35
|
+
engine: {
|
|
36
|
+
open: false,
|
|
37
|
+
expanded: false,
|
|
38
|
+
highlighted: null,
|
|
39
|
+
typed: false,
|
|
40
|
+
listboxId: '',
|
|
41
|
+
itemId: () => '',
|
|
42
|
+
rootRef: { current: null },
|
|
43
|
+
inputRef: { current: null },
|
|
44
|
+
listboxRef: { current: null },
|
|
45
|
+
registerPopup: registerNothing,
|
|
46
|
+
registerItem: registerNothing,
|
|
47
|
+
onKeyDown: noop,
|
|
48
|
+
onChange: noop,
|
|
49
|
+
onClick: noop,
|
|
50
|
+
onBlur: noop,
|
|
51
|
+
hover: noop,
|
|
52
|
+
pick: noop,
|
|
53
|
+
keepFocus: noop,
|
|
54
|
+
markInside: noop,
|
|
55
|
+
},
|
|
56
|
+
inputId: undefined,
|
|
57
|
+
reportInputId: noop,
|
|
58
|
+
labelId: undefined,
|
|
59
|
+
ownLabelId: undefined,
|
|
60
|
+
reportLoading: noop,
|
|
61
|
+
reportEmpty: registerNothing,
|
|
62
|
+
onFieldChange: noop,
|
|
63
|
+
reportPanel: registerNothing,
|
|
64
|
+
placement: { position: 'bottom-left' },
|
|
65
|
+
multiple: false,
|
|
66
|
+
inline: false,
|
|
67
|
+
appendToBody: false,
|
|
68
|
+
};
|
|
69
|
+
const ComboboxContext = createContext(null);
|
|
70
|
+
const useCombobox = () => useContext(ComboboxContext) ?? standalone;
|
|
71
|
+
/** Hands a node to a forwarded ref and to one of the component's own. */
|
|
72
|
+
function setRefs(node, own, forwarded) {
|
|
73
|
+
own.current = node;
|
|
74
|
+
if (typeof forwarded === 'function')
|
|
75
|
+
forwarded(node);
|
|
76
|
+
else if (forwarded)
|
|
77
|
+
forwarded.current = node;
|
|
78
|
+
}
|
|
79
|
+
const ComboboxParts = forwardRef(({ children, below, onSettle, open, defaultOpen = false, onOpenChange, onSelect, keepFirst = false, multiple = false, inline = false, position = 'bottom-left', appendToBody = false, size, label, labelSize, labelProps, horizontal, message, messageColor, fieldClassName, className, ...props }, ref) => {
|
|
80
|
+
const insideField = useInsideField();
|
|
81
|
+
const insideControl = useInsideControl();
|
|
82
|
+
const ownField = rendersOwnField('Combobox', {
|
|
83
|
+
insideField,
|
|
84
|
+
insideControl,
|
|
85
|
+
label,
|
|
86
|
+
message,
|
|
87
|
+
horizontal,
|
|
88
|
+
fieldClassName,
|
|
89
|
+
});
|
|
90
|
+
// The field's own `id`, when `Combobox.Input` was given one, which the
|
|
91
|
+
// label then points at.
|
|
92
|
+
const [givenInputId, setGivenInputId] = useState();
|
|
93
|
+
const { controlId, fieldLabelProps, labelId: listLabelId, } = useAutoLabelId({
|
|
94
|
+
label,
|
|
95
|
+
id: givenInputId,
|
|
96
|
+
labelProps,
|
|
97
|
+
rendersLabel: ownField,
|
|
98
|
+
needsLabelId: true,
|
|
99
|
+
});
|
|
100
|
+
const engine = useComboboxEngine({
|
|
101
|
+
open,
|
|
102
|
+
defaultOpen,
|
|
103
|
+
onOpenChange,
|
|
104
|
+
onSelect,
|
|
105
|
+
onSettle,
|
|
106
|
+
keepFirst,
|
|
107
|
+
multiple,
|
|
108
|
+
inline,
|
|
109
|
+
// A portaled panel has no server markup to point at.
|
|
110
|
+
deferred: appendToBody && !inline,
|
|
111
|
+
});
|
|
112
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
113
|
+
const validSize = validComboboxSizes.includes(size)
|
|
114
|
+
? size
|
|
115
|
+
: undefined;
|
|
116
|
+
const rootClass = usePrefixedClassNames('combobox', {
|
|
117
|
+
[`is-${validSize}`]: !!validSize,
|
|
118
|
+
'is-inline': inline,
|
|
119
|
+
});
|
|
120
|
+
const helpClass = usePrefixedClassNames('help', {
|
|
121
|
+
[`is-${messageColor}`]: !!messageColor,
|
|
122
|
+
});
|
|
123
|
+
// A polite status region, in the page from the start so it's there
|
|
124
|
+
// before anything is written into it, announces `Combobox.Empty` when
|
|
125
|
+
// the user's typing leaves it on screen: once each time that happens,
|
|
126
|
+
// not again on each keystroke while it stays. An empty field isn't a
|
|
127
|
+
// search, so it stays quiet then. While the field's `isLoading`, or a
|
|
128
|
+
// loading Control around the Combobox, says results are on their way, it
|
|
129
|
+
// waits, and it's said once loading ends with the message still there.
|
|
130
|
+
const [inputLoading, setInputLoading] = useState(false);
|
|
131
|
+
const controlLoading = useControlLoading();
|
|
132
|
+
const [hasText, setHasText] = useState(false);
|
|
133
|
+
const emptyRef = useRef(null);
|
|
134
|
+
const [emptyShown, setEmptyShown] = useState(false);
|
|
135
|
+
const announced = engine.typed && hasText && emptyShown && !inputLoading && !controlLoading
|
|
136
|
+
? typedNoMatches
|
|
137
|
+
: nothingToAnnounce;
|
|
138
|
+
// The region reads the text on screen, whatever the message renders.
|
|
139
|
+
const describeEmpty = useCallback(() => {
|
|
140
|
+
const node = emptyRef.current;
|
|
141
|
+
/* istanbul ignore if: it's only announced while it's on screen */
|
|
142
|
+
if (!node)
|
|
143
|
+
return null;
|
|
144
|
+
return spokenText(node);
|
|
145
|
+
}, []);
|
|
146
|
+
const reportEmpty = useCallback((element) => {
|
|
147
|
+
emptyRef.current = element;
|
|
148
|
+
setEmptyShown(true);
|
|
149
|
+
return () => {
|
|
150
|
+
emptyRef.current = null;
|
|
151
|
+
setEmptyShown(false);
|
|
152
|
+
};
|
|
153
|
+
}, []);
|
|
154
|
+
// The menu is placed from here rather than from the part, since this
|
|
155
|
+
// is where the field's element is known by the time the panel mounts.
|
|
156
|
+
const panelRef = useRef(null);
|
|
157
|
+
const [panelShown, setPanelShown] = useState(false);
|
|
158
|
+
const placement = useAnchoredPosition(engine.rootRef, panelRef, {
|
|
159
|
+
active: panelShown,
|
|
160
|
+
position,
|
|
161
|
+
fixed: appendToBody,
|
|
162
|
+
// The gap comes from `--bulma-combobox-menu-offset` as a margin, so
|
|
163
|
+
// the coordinates leave it out and `auto` reads it off the panel.
|
|
164
|
+
offset: 0,
|
|
165
|
+
styledGap: marginGap,
|
|
166
|
+
matchWidth: true,
|
|
167
|
+
// The list filters as the user types, so the menu's height changes.
|
|
168
|
+
followContent: true,
|
|
169
|
+
});
|
|
170
|
+
const { registerPopup } = engine;
|
|
171
|
+
const reportPanel = useCallback((element) => {
|
|
172
|
+
const forget = registerPopup(element);
|
|
173
|
+
panelRef.current = element;
|
|
174
|
+
setPanelShown(true);
|
|
175
|
+
return () => {
|
|
176
|
+
forget();
|
|
177
|
+
panelRef.current = null;
|
|
178
|
+
setPanelShown(false);
|
|
179
|
+
};
|
|
180
|
+
}, [registerPopup]);
|
|
181
|
+
const onFieldChange = (value) => {
|
|
182
|
+
setHasText(value !== '');
|
|
183
|
+
engine.onChange();
|
|
184
|
+
};
|
|
185
|
+
// A new value each render: the engine's state and handlers are, and the
|
|
186
|
+
// parts read the highlight and the open state from it.
|
|
187
|
+
const context = {
|
|
188
|
+
engine,
|
|
189
|
+
inputId: controlId,
|
|
190
|
+
reportInputId: setGivenInputId,
|
|
191
|
+
labelId: listLabelId,
|
|
192
|
+
ownLabelId: label && ownField ? listLabelId : undefined,
|
|
193
|
+
reportLoading: setInputLoading,
|
|
194
|
+
reportEmpty,
|
|
195
|
+
onFieldChange,
|
|
196
|
+
reportPanel,
|
|
197
|
+
placement,
|
|
198
|
+
size: validSize,
|
|
199
|
+
multiple,
|
|
200
|
+
inline,
|
|
201
|
+
appendToBody,
|
|
202
|
+
};
|
|
203
|
+
const { rootRef } = engine;
|
|
204
|
+
const rootRefs = useCallback((node) => setRefs(node, rootRef, ref), [rootRef, ref]);
|
|
205
|
+
const comboboxElement = (jsxs("div", { className: classNames(rootClass, bulmaHelperClasses, className), ...rest, ref: rootRefs, children: [jsx(ComboboxContext.Provider, { value: context, children: children }), jsx(StatusRegion, { items: announced, describe: describeEmpty })] }));
|
|
206
|
+
const fieldElement = below === undefined ? (comboboxElement) : (jsxs("div", { children: [comboboxElement, jsx("div", { onPointerDownCapture: engine.markInside, onMouseDown: engine.keepFocus, children: below })] }));
|
|
207
|
+
const messageEl = message ? jsx("p", { className: helpClass, children: message }) : null;
|
|
208
|
+
if (ownField) {
|
|
209
|
+
return (jsxs(Field, { label: label, labelSize: labelSize, labelProps: fieldLabelProps, horizontal: horizontal, className: fieldClassName, children: [fieldElement, messageEl] }));
|
|
210
|
+
}
|
|
211
|
+
return (jsxs(Fragment, { children: [fieldElement, messageEl] }));
|
|
212
|
+
});
|
|
213
|
+
ComboboxParts.displayName = 'ComboboxParts';
|
|
214
|
+
/**
|
|
215
|
+
* The text field, with the combobox role and the keys, rendered as an `Input`.
|
|
216
|
+
*
|
|
217
|
+
* @function
|
|
218
|
+
* @param {ComboboxInputProps} props - Props for the Combobox.Input component.
|
|
219
|
+
* @param {React.Ref<HTMLInputElement>} ref - Forwarded ref to the `<input>`.
|
|
220
|
+
* @returns {JSX.Element} The field in its Control.
|
|
221
|
+
*/
|
|
222
|
+
const ComboboxInput = forwardRef(({ id, isLoading, controlSize, onKeyDown, onChange, onClick, onBlur, 'aria-autocomplete': ariaAutocomplete, ...props }, ref) => {
|
|
223
|
+
const context = useCombobox();
|
|
224
|
+
const { engine, reportInputId, reportLoading } = context;
|
|
225
|
+
// Inside a caller's Control the field renders none of its own, so the
|
|
226
|
+
// Combobox's size isn't handed to one there.
|
|
227
|
+
const insideControl = useInsideControl();
|
|
228
|
+
useClientLayoutEffect(() => {
|
|
229
|
+
reportInputId(id);
|
|
230
|
+
return () => reportInputId(undefined);
|
|
231
|
+
}, [reportInputId, id]);
|
|
232
|
+
useClientLayoutEffect(() => {
|
|
233
|
+
reportLoading(!!isLoading);
|
|
234
|
+
return () => reportLoading(false);
|
|
235
|
+
}, [reportLoading, isLoading]);
|
|
236
|
+
const { inputRef } = engine;
|
|
237
|
+
const inputRefs = useCallback((node) => setRefs(node, inputRef, ref), [inputRef, ref]);
|
|
238
|
+
const { expanded, highlighted } = engine;
|
|
239
|
+
// An `id` of the field's own reaches the label from a layout effect, so
|
|
240
|
+
// in server markup, and the first render, the label's `for` still names
|
|
241
|
+
// the generated id. Until then the field points at the label itself,
|
|
242
|
+
// unless the caller named it.
|
|
243
|
+
const callerNamed = !!props['aria-label'] || 'aria-labelledby' in props;
|
|
244
|
+
const labelledBy = callerNamed
|
|
245
|
+
? props['aria-labelledby']
|
|
246
|
+
: id !== undefined && id !== context.inputId
|
|
247
|
+
? context.ownLabelId
|
|
248
|
+
: undefined;
|
|
249
|
+
return (jsx(Input, { ...props, ref: inputRefs, id: id ?? context.inputId, "aria-labelledby": labelledBy, isLoading: isLoading, size: context.size, controlSize: controlSize ?? (insideControl ? undefined : context.size), role: "combobox", "aria-autocomplete": ariaAutocomplete ?? 'list', "aria-haspopup": "listbox", "aria-expanded": expanded, "aria-controls": expanded ? engine.listboxId : undefined, "aria-activedescendant": expanded && highlighted !== null
|
|
250
|
+
? engine.itemId(highlighted)
|
|
251
|
+
: undefined, autoComplete: "off", onKeyDown: event => {
|
|
252
|
+
onKeyDown?.(event);
|
|
253
|
+
engine.onKeyDown(event);
|
|
254
|
+
}, onChange: event => {
|
|
255
|
+
onChange?.(event);
|
|
256
|
+
context.onFieldChange(event.target.value);
|
|
257
|
+
}, onClick: event => {
|
|
258
|
+
onClick?.(event);
|
|
259
|
+
if (!event.defaultPrevented)
|
|
260
|
+
engine.onClick();
|
|
261
|
+
}, onBlur: event => {
|
|
262
|
+
onBlur?.(event);
|
|
263
|
+
engine.onBlur(event);
|
|
264
|
+
} }));
|
|
265
|
+
});
|
|
266
|
+
ComboboxInput.displayName = 'ComboboxInput';
|
|
267
|
+
/**
|
|
268
|
+
* The floating panel under the field, on screen while the list is open and its children aren't all empty.
|
|
269
|
+
*
|
|
270
|
+
* @function
|
|
271
|
+
* @param {ComboboxMenuProps} props - Props for the Combobox.Menu component.
|
|
272
|
+
* @returns {JSX.Element | null} The panel, or nothing while the list is closed or its children are all empty.
|
|
273
|
+
*/
|
|
274
|
+
const ComboboxMenu = ({ children, className, style, onMouseDown, onPointerDownCapture, ...props }) => {
|
|
275
|
+
const { engine, inline, appendToBody, placement, reportPanel, size } = useCombobox();
|
|
276
|
+
// Children that are all `null`, `false` or `''` leave no panel, whatever
|
|
277
|
+
// `open` says. This reads the elements it's handed, not what they render.
|
|
278
|
+
const hasContent = flattenChildren(children).some(child => child !== '');
|
|
279
|
+
const shown = engine.open && !inline && hasContent;
|
|
280
|
+
// A portaled panel has no server markup, so it only exists from the
|
|
281
|
+
// commit after hydration. Everything that measures it waits for that.
|
|
282
|
+
const hydrated = useIsHydrated();
|
|
283
|
+
const mounted = shown && (hydrated || !appendToBody);
|
|
284
|
+
const panelRef = useRef(null);
|
|
285
|
+
useClientLayoutEffect(() => {
|
|
286
|
+
const panel = panelRef.current;
|
|
287
|
+
return mounted && panel ? reportPanel(panel) : undefined;
|
|
288
|
+
}, [mounted, reportPanel]);
|
|
289
|
+
const menuClass = usePrefixedClassNames('dropdown-menu', 'combobox-menu', {
|
|
290
|
+
[`is-${placement.position}`]: true,
|
|
291
|
+
'is-portal': appendToBody,
|
|
292
|
+
[`is-${size}`]: !!size,
|
|
293
|
+
});
|
|
294
|
+
const contentClass = usePrefixedClassNames('dropdown-content');
|
|
295
|
+
// Inline, the list is in the flow already.
|
|
296
|
+
if (inline)
|
|
297
|
+
return jsx(Fragment, { children: children });
|
|
298
|
+
if (!shown)
|
|
299
|
+
return null;
|
|
300
|
+
return (jsx(Portal, { disabled: !appendToBody, children: jsx("div", { ...props, ref: panelRef, className: classNames(menuClass, className), style: appendToBody
|
|
301
|
+
? {
|
|
302
|
+
...style,
|
|
303
|
+
position: 'fixed',
|
|
304
|
+
top: placement.top,
|
|
305
|
+
left: placement.left,
|
|
306
|
+
minWidth: placement.minWidth,
|
|
307
|
+
}
|
|
308
|
+
: style, onMouseDown: event => {
|
|
309
|
+
onMouseDown?.(event);
|
|
310
|
+
engine.keepFocus(event);
|
|
311
|
+
}, onPointerDownCapture: event => {
|
|
312
|
+
onPointerDownCapture?.(event);
|
|
313
|
+
engine.markInside(event);
|
|
314
|
+
}, children: jsx("div", { className: contentClass, children: children }) }) }));
|
|
315
|
+
};
|
|
316
|
+
ComboboxMenu.displayName = 'ComboboxMenu';
|
|
317
|
+
/**
|
|
318
|
+
* The list of items, a `listbox` named by the label that names the field.
|
|
319
|
+
*
|
|
320
|
+
* @function
|
|
321
|
+
* @param {ComboboxListProps} props - Props for the Combobox.List component.
|
|
322
|
+
* @returns {JSX.Element} The listbox.
|
|
323
|
+
*/
|
|
324
|
+
const ComboboxList = ({ children, className, onMouseDown, ...props }) => {
|
|
325
|
+
const { engine, labelId, multiple } = useCombobox();
|
|
326
|
+
const { listboxRef, listboxId, keepFocus } = engine;
|
|
327
|
+
const listClass = usePrefixedClassNames('combobox-list');
|
|
328
|
+
// A name the caller gave the list wins over the label's.
|
|
329
|
+
const callerNamed = !!props['aria-label'] || 'aria-labelledby' in props;
|
|
330
|
+
return (jsx("div", { className: classNames(listClass, className), ...props, ref: listboxRef, id: listboxId || undefined, role: "listbox", "aria-labelledby": callerNamed ? props['aria-labelledby'] : labelId, "aria-label": callerNamed ? props['aria-label'] : labelId ? undefined : 'Suggestions', "aria-multiselectable": multiple || undefined, onMouseDown: event => {
|
|
331
|
+
onMouseDown?.(event);
|
|
332
|
+
keepFocus(event);
|
|
333
|
+
}, children: children }));
|
|
334
|
+
};
|
|
335
|
+
ComboboxList.displayName = 'ComboboxList';
|
|
336
|
+
/**
|
|
337
|
+
* One choice in the list, with a check mark while it's `checked`.
|
|
338
|
+
*
|
|
339
|
+
* @function
|
|
340
|
+
* @param {ComboboxItemProps} props - Props for the Combobox.Item component.
|
|
341
|
+
* @returns {JSX.Element} The item.
|
|
342
|
+
*/
|
|
343
|
+
const ComboboxItem = ({ value, disabled = false, checked = false, children, className, onClick, onPointerMove, ...props }) => {
|
|
344
|
+
const { engine, multiple } = useCombobox();
|
|
345
|
+
const itemRef = useRef(null);
|
|
346
|
+
const { registerItem } = engine;
|
|
347
|
+
useClientLayoutEffect(() => registerItem(itemRef.current, {
|
|
348
|
+
value,
|
|
349
|
+
disabled,
|
|
350
|
+
checked,
|
|
351
|
+
}), [registerItem, value, disabled, checked]);
|
|
352
|
+
const highlighted = engine.highlighted === value;
|
|
353
|
+
const itemClass = usePrefixedClassNames('dropdown-item', 'combobox-item', {
|
|
354
|
+
'is-active': highlighted,
|
|
355
|
+
'is-checked': checked,
|
|
356
|
+
'is-disabled': disabled,
|
|
357
|
+
});
|
|
358
|
+
const checkClass = usePrefixedClassNames('combobox-check');
|
|
359
|
+
// One value: the highlighted option, as the ARIA Authoring Practices'
|
|
360
|
+
// combobox has it, and no attribute on the rest. Several: each option says
|
|
361
|
+
// whether it's chosen. Never a disabled option.
|
|
362
|
+
const selected = disabled
|
|
363
|
+
? undefined
|
|
364
|
+
: multiple
|
|
365
|
+
? checked
|
|
366
|
+
: highlighted || undefined;
|
|
367
|
+
return (jsxs("div", { className: classNames(itemClass, className), ...props, ref: itemRef, id: engine.itemId(value) || undefined, role: "option", "aria-selected": selected, "aria-disabled": disabled || undefined, onClick: event => {
|
|
368
|
+
onClick?.(event);
|
|
369
|
+
if (!disabled && !event.defaultPrevented)
|
|
370
|
+
engine.pick(value);
|
|
371
|
+
}, onPointerMove: event => {
|
|
372
|
+
onPointerMove?.(event);
|
|
373
|
+
if (!disabled && !highlighted)
|
|
374
|
+
engine.hover(value);
|
|
375
|
+
}, children: [checked && (jsx("span", { className: checkClass, "aria-hidden": "true", children: jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", width: "1em", height: "1em", focusable: "false", children: jsx("polyline", { points: "3 8.5 6.5 12 13 4.5" }) }) })), children] }));
|
|
376
|
+
};
|
|
377
|
+
ComboboxItem.displayName = 'ComboboxItem';
|
|
378
|
+
/**
|
|
379
|
+
* Items under a heading, a `group` named by it.
|
|
380
|
+
*
|
|
381
|
+
* @function
|
|
382
|
+
* @param {ComboboxGroupProps} props - Props for the Combobox.Group component.
|
|
383
|
+
* @returns {JSX.Element} The group and its heading.
|
|
384
|
+
*/
|
|
385
|
+
const ComboboxGroup = ({ label, children, className, ...props }) => {
|
|
386
|
+
const labelId = useId();
|
|
387
|
+
const groupClass = usePrefixedClassNames('combobox-group');
|
|
388
|
+
const labelClass = usePrefixedClassNames('combobox-group-label');
|
|
389
|
+
// A name the caller gave the group wins over its heading's, as on the list.
|
|
390
|
+
const callerNamed = !!props['aria-label'] || 'aria-labelledby' in props;
|
|
391
|
+
return (jsxs("div", { className: classNames(groupClass, className), ...props, role: "group", "aria-labelledby": callerNamed ? props['aria-labelledby'] : labelId, children: [jsx("div", { className: labelClass, id: labelId, children: label }), children] }));
|
|
392
|
+
};
|
|
393
|
+
ComboboxGroup.displayName = 'ComboboxGroup';
|
|
394
|
+
/**
|
|
395
|
+
* The message shown in place of the list when nothing matches, announced when typing brings it on screen.
|
|
396
|
+
*
|
|
397
|
+
* @function
|
|
398
|
+
* @param {ComboboxEmptyProps} props - Props for the Combobox.Empty component.
|
|
399
|
+
* @returns {JSX.Element} The message.
|
|
400
|
+
*/
|
|
401
|
+
const ComboboxEmpty = ({ children, className, onMouseDown, ...props }) => {
|
|
402
|
+
const { engine, reportEmpty } = useCombobox();
|
|
403
|
+
const emptyRef = useRef(null);
|
|
404
|
+
useClientLayoutEffect(() => reportEmpty(emptyRef.current), [reportEmpty]);
|
|
405
|
+
const emptyClass = usePrefixedClassNames('combobox-empty');
|
|
406
|
+
return (jsx("div", { className: classNames(emptyClass, className), ...props, ref: emptyRef, onMouseDown: event => {
|
|
407
|
+
onMouseDown?.(event);
|
|
408
|
+
engine.keepFocus(event);
|
|
409
|
+
}, children: children }));
|
|
410
|
+
};
|
|
411
|
+
ComboboxEmpty.displayName = 'ComboboxEmpty';
|
|
412
|
+
/** An entry in `data` as an item: a string is its own label. */
|
|
413
|
+
const toItem = (entry) => typeof entry === 'string' ? { value: entry, label: entry } : entry;
|
|
414
|
+
const labelOf = (item) => item.label ?? item.value;
|
|
415
|
+
/** One value or several, as a list: one value is a list of none or one. */
|
|
416
|
+
const asList = (value) => value == null ? [] : Array.isArray(value) ? value : [value];
|
|
417
|
+
/**
|
|
418
|
+
* The Combobox given `data`. It renders the parts itself, filters the items
|
|
419
|
+
* by label as the user types, and keeps the value, so a pick goes through
|
|
420
|
+
* the same parts, keys and ARIA as one built by hand.
|
|
421
|
+
*/
|
|
422
|
+
const ComboboxFromData = forwardRef((props, ref) => {
|
|
423
|
+
const { data, value, defaultValue,
|
|
424
|
+
// Called below through `props`, which knows which value it takes.
|
|
425
|
+
onChange: _onChange, multiple = false, placeholder, name, empty, itemTemplate, onSelect, onOpenChange, inline = false, disabled = false, required = false, form, id, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ariaCloseLabel, children, ...rest } = props;
|
|
426
|
+
// A name given in place of a label is the field's and the list's, not
|
|
427
|
+
// the wrapper's. Only the ones given are passed, since an
|
|
428
|
+
// `aria-labelledby` that's there at all, even undefined, keeps the
|
|
429
|
+
// label's name off the field.
|
|
430
|
+
const named = {
|
|
431
|
+
...(ariaLabel !== undefined && { 'aria-label': ariaLabel }),
|
|
432
|
+
...(ariaLabelledBy !== undefined && {
|
|
433
|
+
'aria-labelledby': ariaLabelledBy,
|
|
434
|
+
}),
|
|
435
|
+
};
|
|
436
|
+
if (flattenChildren(children).length > 0) {
|
|
437
|
+
warnOnce('Combobox:data-children', '[bestax-bulma] <Combobox data> renders its own field, menu and ' +
|
|
438
|
+
'items, so its children are ignored. Give it data or build it ' +
|
|
439
|
+
'from parts, not both.');
|
|
440
|
+
}
|
|
441
|
+
const items = data.map(toItem);
|
|
442
|
+
const labelFor = (picked) => {
|
|
443
|
+
const item = items.find(entry => entry.value === picked);
|
|
444
|
+
return item ? labelOf(item) : picked;
|
|
445
|
+
};
|
|
446
|
+
const controlled = value !== undefined;
|
|
447
|
+
const [ownValue, setOwnValue] = useState(() => asList(defaultValue));
|
|
448
|
+
const picked = controlled ? asList(value) : ownValue;
|
|
449
|
+
const change = (next) => {
|
|
450
|
+
if (!controlled)
|
|
451
|
+
setOwnValue(next);
|
|
452
|
+
if (props.multiple === true)
|
|
453
|
+
props.onChange?.(next);
|
|
454
|
+
else
|
|
455
|
+
props.onChange?.(next[0] ?? null);
|
|
456
|
+
};
|
|
457
|
+
// What's typed, or `null` while the field shows the value instead. The
|
|
458
|
+
// ref holds it as it is now, since a pick sets it and the list's closing
|
|
459
|
+
// reads it in the same event, before a render.
|
|
460
|
+
const [query, setQueryState] = useState(null);
|
|
461
|
+
const queryRef = useRef(null);
|
|
462
|
+
const setQuery = (next) => {
|
|
463
|
+
queryRef.current = next;
|
|
464
|
+
setQueryState(next);
|
|
465
|
+
};
|
|
466
|
+
// Once the list closes or the field is left, the field shows the value
|
|
467
|
+
// again: typed text goes back to the pick, and with one value a field
|
|
468
|
+
// left empty clears it.
|
|
469
|
+
const settle = () => {
|
|
470
|
+
if (!multiple && queryRef.current === '' && picked.length > 0) {
|
|
471
|
+
change([]);
|
|
472
|
+
}
|
|
473
|
+
setQuery(null);
|
|
474
|
+
};
|
|
475
|
+
const term = (query ?? '').trim().toLowerCase();
|
|
476
|
+
const shown = items.filter(item => labelOf(item).toLowerCase().includes(term));
|
|
477
|
+
const inputRef = useRef(null);
|
|
478
|
+
const select = (pick) => {
|
|
479
|
+
onSelect?.(pick);
|
|
480
|
+
if (multiple) {
|
|
481
|
+
change(picked.includes(pick)
|
|
482
|
+
? picked.filter(v => v !== pick)
|
|
483
|
+
: [...picked, pick]);
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
if (picked[0] !== pick)
|
|
487
|
+
change([pick]);
|
|
488
|
+
setQuery(null);
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
const remove = (pick) => {
|
|
492
|
+
change(picked.filter(v => v !== pick));
|
|
493
|
+
inputRef.current?.focus();
|
|
494
|
+
};
|
|
495
|
+
// The picks, for the field's description, since its text is the search.
|
|
496
|
+
const tagsId = useId();
|
|
497
|
+
const tagIds = picked.map((_, index) => `${tagsId}-${index}`);
|
|
498
|
+
// Each group where `data` first names it, and the items with none
|
|
499
|
+
// together where the first of them is.
|
|
500
|
+
const sections = new Map();
|
|
501
|
+
for (const item of shown) {
|
|
502
|
+
const group = item.group ?? '';
|
|
503
|
+
sections.set(group, [...(sections.get(group) ?? []), item]);
|
|
504
|
+
}
|
|
505
|
+
const renderItem = (item) => (jsx(ComboboxItem, { value: item.value, disabled: disabled || item.disabled, checked: picked.includes(item.value), children: itemTemplate ? itemTemplate(item) : labelOf(item) }, item.value));
|
|
506
|
+
const list = shown.length > 0 ? (jsx(ComboboxList, { ...named, children: Array.from(sections, ([group, members]) => group ? (jsx(ComboboxGroup, { label: group, children: members.map(renderItem) }, group)) : (members.map(renderItem))) })) : empty ? (jsx(ComboboxEmpty, { children: empty })) : null;
|
|
507
|
+
return (jsxs(ComboboxParts, { ...rest, ref: ref, multiple: multiple, inline: inline, onSelect: select, onSettle: settle, onOpenChange: next => {
|
|
508
|
+
if (!next)
|
|
509
|
+
settle();
|
|
510
|
+
onOpenChange?.(next);
|
|
511
|
+
}, below: multiple
|
|
512
|
+
? picked.length > 0 && (jsx(Tags, { mt: "2", children: picked.map((v, index) => (jsxs(Tag, { children: [jsx("span", { id: tagIds[index], children: labelFor(v) }), !disabled && (jsx(Delete, { size: "small", ariaLabel: ariaCloseLabel || `Remove ${labelFor(v)}`, "aria-describedby": ariaCloseLabel ? tagIds[index] : undefined, onClick: () => remove(v) }))] }, v))) }))
|
|
513
|
+
: undefined, children: [jsx(ComboboxInput, { ...named, ref: inputRef, id: id, disabled: disabled,
|
|
514
|
+
// The browser's check reads the field's text, and only a pick
|
|
515
|
+
// fills it for good, so it's required until there is one.
|
|
516
|
+
required: required && picked.length === 0, "aria-required": required || undefined, form: form, "aria-describedby": multiple && picked.length > 0 ? tagIds.join(' ') : undefined, placeholder: placeholder, value: query ??
|
|
517
|
+
(multiple || picked.length === 0 ? '' : labelFor(picked[0])), onChange: event => setQuery(event.target.value), onBlur: settle }), inline ? list : jsx(ComboboxMenu, { children: list }), name &&
|
|
518
|
+
(multiple ? picked : [picked[0] ?? '']).map(v => (jsx("input", { type: "hidden", name: name, value: v, form: form, disabled: disabled }, v)))] }));
|
|
519
|
+
});
|
|
520
|
+
ComboboxFromData.displayName = 'ComboboxFromData';
|
|
521
|
+
/**
|
|
522
|
+
* The `Combobox` component is a text field with a list to pick from under it, for a searchable select, a multi-select with tags, grouped items or a server search: given `data` it does the rest, and built from parts it leaves the data to you.
|
|
523
|
+
*
|
|
524
|
+
* Given `data`, it's one element. It renders the field, the menu, the list,
|
|
525
|
+
* the groups and the empty message, filters the items by label as the user
|
|
526
|
+
* types, and keeps the value, which `onChange` reports. With `multiple`, the
|
|
527
|
+
* picks show as tags under the field.
|
|
528
|
+
*
|
|
529
|
+
* Built from parts, the app owns the data: what's in the list, how it's
|
|
530
|
+
* filtered, and the value. A pick calls `onSelect` with the item's `value`,
|
|
531
|
+
* and the Combobox keeps no value and never changes the text in the field,
|
|
532
|
+
* so the app sets both there. Nothing filters the items either, so a
|
|
533
|
+
* server's answer shows as it came. For a free-text field with suggestions,
|
|
534
|
+
* `Autocomplete` is the simpler choice.
|
|
535
|
+
*
|
|
536
|
+
* Either way, focus stays in the field, and the highlighted item is its
|
|
537
|
+
* `aria-activedescendant`. With one value, the highlighted item is the
|
|
538
|
+
* `aria-selected` one, as the ARIA Authoring Practices' combobox does it, and
|
|
539
|
+
* a `checked` item shows a check mark and takes the highlight when the list
|
|
540
|
+
* opens. With `multiple`, `checked` is each item's `aria-selected`. Tab and
|
|
541
|
+
* focus leaving close the menu without picking.
|
|
542
|
+
*
|
|
543
|
+
* When typing leaves the empty message on screen, its text is announced
|
|
544
|
+
* through a polite status region, once, after any loading ends. Result
|
|
545
|
+
* counts aren't announced: a screen reader reads the list's size from the
|
|
546
|
+
* markup, and a count on every keystroke talks over the typing. Show one in
|
|
547
|
+
* a `role="status"` line beside the field when it matters.
|
|
548
|
+
*
|
|
549
|
+
* @function
|
|
550
|
+
* @param {ComboboxProps} props - Props for the Combobox component.
|
|
551
|
+
* @param {React.Ref<HTMLDivElement>} ref - Forwarded ref to the `div.combobox`.
|
|
552
|
+
* @returns {JSX.Element} The field and its list, in a `Field` with the label unless one is around it.
|
|
553
|
+
*
|
|
554
|
+
* @example
|
|
555
|
+
* <Combobox
|
|
556
|
+
* label="Country"
|
|
557
|
+
* data={['Canada', 'France', 'Japan']}
|
|
558
|
+
* placeholder="Pick a country"
|
|
559
|
+
* empty="No countries match"
|
|
560
|
+
* />
|
|
561
|
+
*
|
|
562
|
+
* @example
|
|
563
|
+
* <Combobox label="Country" onSelect={pick}>
|
|
564
|
+
* <Combobox.Input value={query} onChange={e => setQuery(e.target.value)} />
|
|
565
|
+
* <Combobox.Menu>
|
|
566
|
+
* {matches.length > 0 ? (
|
|
567
|
+
* <Combobox.List>
|
|
568
|
+
* {matches.map(c => (
|
|
569
|
+
* <Combobox.Item key={c} value={c} checked={c === value}>
|
|
570
|
+
* {c}
|
|
571
|
+
* </Combobox.Item>
|
|
572
|
+
* ))}
|
|
573
|
+
* </Combobox.List>
|
|
574
|
+
* ) : (
|
|
575
|
+
* <Combobox.Empty>No countries match</Combobox.Empty>
|
|
576
|
+
* )}
|
|
577
|
+
* </Combobox.Menu>
|
|
578
|
+
* </Combobox>
|
|
579
|
+
*/
|
|
580
|
+
const ComboboxRoot = forwardRef((props, ref) => props.data === undefined ? (jsx(ComboboxParts, { ...props, ref: ref })) : (jsx(ComboboxFromData, { ...props, ref: ref })));
|
|
581
|
+
/** Combobox with Input, Menu, List, Item, Group and Empty sub-components. */
|
|
582
|
+
const Combobox = withSubComponents(ComboboxRoot, {
|
|
583
|
+
Input: ComboboxInput,
|
|
584
|
+
Menu: ComboboxMenu,
|
|
585
|
+
List: ComboboxList,
|
|
586
|
+
Item: ComboboxItem,
|
|
587
|
+
Group: ComboboxGroup,
|
|
588
|
+
Empty: ComboboxEmpty,
|
|
589
|
+
}, 'Combobox');
|
|
590
|
+
|
|
591
|
+
export { Combobox, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxList, ComboboxMenu };
|
|
592
|
+
//# sourceMappingURL=Combobox.js.map
|