@allxsmith/bestax-bulma 5.51.0 → 5.52.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.
Files changed (55) hide show
  1. package/dist/bestax.css +1 -1
  2. package/dist/bestax.css.map +1 -1
  3. package/dist/esm/components/Command.js +637 -0
  4. package/dist/esm/components/Command.js.map +1 -0
  5. package/dist/esm/form/Autocomplete.js +2 -2
  6. package/dist/esm/form/CheckboxCard.js +118 -0
  7. package/dist/esm/form/CheckboxCard.js.map +1 -0
  8. package/dist/esm/form/Checkboxes.js +14 -3
  9. package/dist/esm/form/Checkboxes.js.map +1 -1
  10. package/dist/esm/form/RadioCard.js +85 -0
  11. package/dist/esm/form/RadioCard.js.map +1 -0
  12. package/dist/esm/form/Radios.js +14 -3
  13. package/dist/esm/form/Radios.js.map +1 -1
  14. package/dist/esm/form/groupFieldset.js +38 -0
  15. package/dist/esm/form/groupFieldset.js.map +1 -0
  16. package/dist/esm/form/useAutoLabelId.js +1 -1
  17. package/dist/esm/helpers/useComboboxEngine.js +3 -3
  18. package/dist/esm/helpers/useFocusTrap.js +2 -2
  19. package/dist/esm/helpers/useFocusTrap.js.map +1 -1
  20. package/dist/extras.css +1 -1
  21. package/dist/extras.css.map +1 -1
  22. package/dist/index.cjs +2429 -1566
  23. package/dist/index.cjs.map +1 -1
  24. package/dist/index.esm.js +3 -0
  25. package/dist/index.esm.js.map +1 -1
  26. package/dist/types/components/Command.d.ts +318 -0
  27. package/dist/types/form/CheckboxCard.d.ts +94 -0
  28. package/dist/types/form/Checkboxes.d.ts +13 -0
  29. package/dist/types/form/RadioCard.d.ts +81 -0
  30. package/dist/types/form/Radios.d.ts +13 -0
  31. package/dist/types/form/groupFieldset.d.ts +32 -0
  32. package/dist/types/index.d.ts +3 -0
  33. package/dist/types-cjs/components/Command.d.ts +318 -0
  34. package/dist/types-cjs/form/CheckboxCard.d.ts +94 -0
  35. package/dist/types-cjs/form/Checkboxes.d.ts +13 -0
  36. package/dist/types-cjs/form/RadioCard.d.ts +81 -0
  37. package/dist/types-cjs/form/Radios.d.ts +13 -0
  38. package/dist/types-cjs/form/groupFieldset.d.ts +32 -0
  39. package/dist/types-cjs/index.d.ts +3 -0
  40. package/dist/versions/bestax-no-dark-mode.css +1 -1
  41. package/dist/versions/bestax-no-dark-mode.css.map +1 -1
  42. package/dist/versions/bestax-no-helpers-prefixed.css +1 -1
  43. package/dist/versions/bestax-no-helpers-prefixed.css.map +1 -1
  44. package/dist/versions/bestax-no-helpers.css +1 -1
  45. package/dist/versions/bestax-no-helpers.css.map +1 -1
  46. package/dist/versions/bestax-prefixed.css +1 -1
  47. package/dist/versions/bestax-prefixed.css.map +1 -1
  48. package/package.json +1 -1
  49. package/src/scss/components/_command.scss +259 -0
  50. package/src/scss/components/_index.scss +1 -0
  51. package/src/scss/form/_checkbox.scss +44 -0
  52. package/src/scss/form/_checkboxcard.scss +355 -0
  53. package/src/scss/form/_index.scss +2 -0
  54. package/src/scss/form/_radio.scss +44 -0
  55. package/src/scss/form/_radiocard.scss +291 -0
@@ -0,0 +1,637 @@
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, useEffect, useContext, useId } 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 { useClientLayoutEffect } from '../helpers/useClientLayoutEffect.js';
8
+ import { useLatestRef } from '../helpers/useLatestRef.js';
9
+ import { flattenChildren } from '../helpers/flattenChildren.js';
10
+ import { warnOnce } from '../helpers/devWarnings.js';
11
+ import { isModalOverlayOpen } from '../helpers/overlayStack.js';
12
+ import { spokenText, StatusRegion } from '../helpers/statusRegion.js';
13
+ import { useComboboxEngine } from '../helpers/useComboboxEngine.js';
14
+ import { Input } from '../form/Input.js';
15
+ import { Modal } from './Modal.js';
16
+
17
+ /** What names a Command that's given no `label`. */
18
+ const defaultLabel = 'Commands';
19
+ /** What a Command given `actions` shows when nothing matches, unless it's given `empty`. */
20
+ const defaultEmpty = 'No results';
21
+ // What the status region announces: the `Command.Empty` content, as the one
22
+ // item it's handed while that content is on screen because of the user's
23
+ // typing.
24
+ const typedNoMatches$2 = [{ id: 'empty' }];
25
+ const nothingToAnnounce$2 = [];
26
+ /** Whether a slot was given something to show. */
27
+ const isShown = (node) => node !== undefined && node !== null && node !== false && node !== '';
28
+ const noop$1 = () => { };
29
+ const registerNothing$1 = () => noop$1;
30
+ // What a part rendered outside a Command reads: nothing to report to, so it
31
+ // renders its markup and does nothing.
32
+ const standalone$1 = {
33
+ engine: {
34
+ open: false,
35
+ expanded: false,
36
+ highlighted: null,
37
+ typed: false,
38
+ listboxId: '',
39
+ itemId: () => '',
40
+ rootRef: { current: null },
41
+ inputRef: { current: null },
42
+ listboxRef: { current: null },
43
+ registerPopup: registerNothing$1,
44
+ registerItem: registerNothing$1,
45
+ onKeyDown: noop$1,
46
+ onChange: noop$1,
47
+ onClick: noop$1,
48
+ onBlur: noop$1,
49
+ hover: noop$1,
50
+ pick: noop$1,
51
+ keepFocus: noop$1,
52
+ markInside: noop$1,
53
+ },
54
+ label: defaultLabel,
55
+ query: '',
56
+ onFieldChange: noop$1,
57
+ reportLoading: noop$1,
58
+ reportEmpty: registerNothing$1,
59
+ registerHandler: registerNothing$1,
60
+ };
61
+ const CommandContext = createContext(null);
62
+ const useCommand = () => useContext(CommandContext) ?? standalone$1;
63
+ /** Hands a node to a forwarded ref and to one of the component's own. */
64
+ function setRefs$1(node, own, forwarded) {
65
+ own.current = node;
66
+ if (typeof forwarded === 'function')
67
+ forwarded(node);
68
+ else if (forwarded)
69
+ forwarded.current = node;
70
+ }
71
+ /**
72
+ * The default filter: the actions whose label, keywords or description hold
73
+ * the search, ignoring case and the spaces around it, label matches first and
74
+ * the order otherwise kept. An empty search keeps them all.
75
+ */
76
+ const filterActions = (query, actions) => {
77
+ const term = query.trim().toLowerCase();
78
+ if (term === '')
79
+ return actions;
80
+ const byLabel = [];
81
+ const byOther = [];
82
+ for (const action of actions) {
83
+ if (action.label.toLowerCase().includes(term)) {
84
+ byLabel.push(action);
85
+ }
86
+ else if (action.description?.toLowerCase().includes(term) ||
87
+ action.keywords?.some(keyword => keyword.toLowerCase().includes(term))) {
88
+ byOther.push(action);
89
+ }
90
+ }
91
+ return [...byLabel, ...byOther];
92
+ };
93
+ /** The search icon at the start of the field. */
94
+ const SearchIcon = () => (jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", width: "1em", height: "1em", focusable: "false", children: [jsx("circle", { cx: "7", cy: "7", r: "4.75" }), jsx("path", { d: "M10.5 10.5L14 14" })] }));
95
+ /**
96
+ * The Command built from parts: the field, the list, the keys and the run,
97
+ * with the items and their filtering left to the app.
98
+ */
99
+ const CommandParts = forwardRef(({ children, label, query: controlledQuery, defaultQuery = '', onQueryChange, onSelect, className, ...props }, ref) => {
100
+ const [ownQuery, setOwnQuery] = useState(defaultQuery);
101
+ const query = controlledQuery ?? ownQuery;
102
+ // Each item's `onSelect`, by value, read when it runs.
103
+ const handlersRef = useRef(new Map());
104
+ const registerHandler = useCallback((value, handler) => {
105
+ const handlers = handlersRef.current;
106
+ handlers.set(value, handler);
107
+ return () => {
108
+ if (handlers.get(value) === handler)
109
+ handlers.delete(value);
110
+ };
111
+ }, []);
112
+ const run = (value) => {
113
+ handlersRef.current.get(value)?.current?.();
114
+ onSelect?.(value);
115
+ };
116
+ // Inline: the list is always on screen, nothing opens or closes it, and
117
+ // Escape goes on to what's around it. `keepFirst` keeps the highlight on
118
+ // the top match, so Enter runs it.
119
+ const engine = useComboboxEngine({
120
+ defaultOpen: true,
121
+ onSelect: run,
122
+ keepFirst: true,
123
+ multiple: false,
124
+ inline: true,
125
+ deferred: false,
126
+ });
127
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
128
+ const rootClass = usePrefixedClassNames('command');
129
+ // A polite status region, in the page from the start so it's there
130
+ // before anything is written into it, announces `Command.Empty` when the
131
+ // user's typing leaves it on screen: once each time that happens, not
132
+ // again on each keystroke while it stays. An empty search isn't a search,
133
+ // so it stays quiet then, and while the field's `isLoading` says results
134
+ // are on their way, it waits.
135
+ const [inputLoading, setInputLoading] = useState(false);
136
+ const emptyRef = useRef(null);
137
+ const [emptyShown, setEmptyShown] = useState(false);
138
+ const announced = engine.typed && query !== '' && emptyShown && !inputLoading
139
+ ? typedNoMatches$2
140
+ : nothingToAnnounce$2;
141
+ // The region reads the text on screen, whatever the message renders.
142
+ const describeEmpty = useCallback(() => {
143
+ const node = emptyRef.current;
144
+ /* istanbul ignore if: it's only announced while it's on screen */
145
+ if (!node)
146
+ return null;
147
+ return spokenText(node);
148
+ }, []);
149
+ const reportEmpty = useCallback((element) => {
150
+ emptyRef.current = element;
151
+ setEmptyShown(true);
152
+ return () => {
153
+ emptyRef.current = null;
154
+ setEmptyShown(false);
155
+ };
156
+ }, []);
157
+ const onFieldChange = (value) => {
158
+ if (controlledQuery === undefined)
159
+ setOwnQuery(value);
160
+ onQueryChange?.(value);
161
+ engine.onChange();
162
+ };
163
+ // A new value each render: the engine's state and handlers are, and the
164
+ // parts read the highlight from it.
165
+ const context = {
166
+ engine,
167
+ label: label || defaultLabel,
168
+ query,
169
+ onFieldChange,
170
+ reportLoading: setInputLoading,
171
+ reportEmpty,
172
+ registerHandler,
173
+ };
174
+ const { rootRef } = engine;
175
+ const rootRefs = useCallback((node) => setRefs$1(node, rootRef, ref), [rootRef, ref]);
176
+ return (jsxs("div", { className: classNames(rootClass, bulmaHelperClasses, className), ...rest, ref: rootRefs, children: [jsx(CommandContext.Provider, { value: context, children: children }), jsx(StatusRegion, { items: announced, describe: describeEmpty })] }));
177
+ });
178
+ CommandParts.displayName = 'CommandParts';
179
+ /**
180
+ * The search field, with the combobox role and the keys, rendered as an `Input` with a search icon.
181
+ *
182
+ * @function
183
+ * @param {CommandInputProps} props - Props for the Command.Input component.
184
+ * @param {React.Ref<HTMLInputElement>} ref - Forwarded ref to the `<input>`.
185
+ * @returns {JSX.Element} The field in its Control.
186
+ */
187
+ const CommandInput = forwardRef(({ isLoading, iconLeft, iconLeftName, onKeyDown, onChange, onClick, onBlur, 'aria-autocomplete': ariaAutocomplete, ...props }, ref) => {
188
+ const context = useCommand();
189
+ const { engine, reportLoading } = context;
190
+ useClientLayoutEffect(() => {
191
+ reportLoading(!!isLoading);
192
+ return () => reportLoading(false);
193
+ }, [reportLoading, isLoading]);
194
+ const { inputRef } = engine;
195
+ const inputRefs = useCallback((node) => setRefs$1(node, inputRef, ref), [inputRef, ref]);
196
+ const { expanded, highlighted } = engine;
197
+ // A name the caller gave the field wins over the Command's `label`.
198
+ const callerNamed = !!props['aria-label'] || 'aria-labelledby' in props;
199
+ return (jsx(Input, { autoCorrect: "off", spellCheck: false, ...props, ref: inputRefs, value: context.query, iconLeft: iconLeft === undefined && !iconLeftName ? jsx(SearchIcon, {}) : iconLeft, iconLeftName: iconLeftName, isLoading: isLoading, "aria-label": callerNamed ? props['aria-label'] : context.label, role: "combobox", "aria-autocomplete": ariaAutocomplete ?? 'list', "aria-haspopup": "listbox", "aria-expanded": expanded, "aria-controls": expanded ? engine.listboxId : undefined, "aria-activedescendant": expanded && highlighted !== null
200
+ ? engine.itemId(highlighted)
201
+ : undefined, autoComplete: "off", onKeyDown: event => {
202
+ onKeyDown?.(event);
203
+ engine.onKeyDown(event);
204
+ }, onChange: event => {
205
+ onChange?.(event);
206
+ context.onFieldChange(event.target.value);
207
+ }, onClick: event => {
208
+ onClick?.(event);
209
+ if (!event.defaultPrevented)
210
+ engine.onClick();
211
+ }, onBlur: event => {
212
+ onBlur?.(event);
213
+ engine.onBlur(event);
214
+ } }));
215
+ });
216
+ CommandInput.displayName = 'CommandInput';
217
+ /**
218
+ * The list of items, a `listbox` named by the Command's `label`.
219
+ *
220
+ * @function
221
+ * @param {CommandListProps} props - Props for the Command.List component.
222
+ * @returns {JSX.Element} The listbox.
223
+ */
224
+ const CommandList = ({ children, className, onMouseDown, ...props }) => {
225
+ const { engine, label } = useCommand();
226
+ const { listboxRef, listboxId, keepFocus } = engine;
227
+ const listClass = usePrefixedClassNames('command-list');
228
+ // A name the caller gave the list wins over the Command's.
229
+ const callerNamed = !!props['aria-label'] || 'aria-labelledby' in props;
230
+ return (jsx("div", { className: classNames(listClass, className), ...props, ref: listboxRef, id: listboxId || undefined, role: "listbox", "aria-label": callerNamed ? props['aria-label'] : label, onMouseDown: event => {
231
+ onMouseDown?.(event);
232
+ keepFocus(event);
233
+ }, children: children }));
234
+ };
235
+ CommandList.displayName = 'CommandList';
236
+ /**
237
+ * Items under a heading, a `group` named by it.
238
+ *
239
+ * @function
240
+ * @param {CommandGroupProps} props - Props for the Command.Group component.
241
+ * @returns {JSX.Element} The group and its heading.
242
+ */
243
+ const CommandGroup = ({ label, children, className, ...props }) => {
244
+ const labelId = useId();
245
+ const groupClass = usePrefixedClassNames('command-group');
246
+ const labelClass = usePrefixedClassNames('command-group-label');
247
+ // A name the caller gave the group wins over its heading's, as on the list.
248
+ const callerNamed = !!props['aria-label'] || 'aria-labelledby' in props;
249
+ 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] }));
250
+ };
251
+ CommandGroup.displayName = 'CommandGroup';
252
+ /**
253
+ * One thing to do in the list, named by its label and described by its description and hint.
254
+ *
255
+ * @function
256
+ * @param {CommandItemProps} props - Props for the Command.Item component.
257
+ * @returns {JSX.Element} The item.
258
+ */
259
+ const CommandItem = ({ value, disabled = false, onSelect, icon, description, hint, children, className, onClick, onPointerMove, ...props }) => {
260
+ const { engine, registerHandler } = useCommand();
261
+ const itemRef = useRef(null);
262
+ const { registerItem } = engine;
263
+ useClientLayoutEffect(() => registerItem(itemRef.current, {
264
+ value,
265
+ disabled,
266
+ checked: false,
267
+ }), [registerItem, value, disabled]);
268
+ const onSelectRef = useLatestRef(onSelect);
269
+ useClientLayoutEffect(() => registerHandler(value, onSelectRef), [registerHandler, value, onSelectRef]);
270
+ const highlighted = engine.highlighted === value;
271
+ const itemClass = usePrefixedClassNames('command-item', {
272
+ 'is-active': highlighted,
273
+ 'is-disabled': disabled,
274
+ });
275
+ const iconClass = usePrefixedClassNames('command-item-icon');
276
+ const bodyClass = usePrefixedClassNames('command-item-body');
277
+ const labelClass = usePrefixedClassNames('command-item-label');
278
+ const descriptionClass = usePrefixedClassNames('command-item-description');
279
+ const hintClass = usePrefixedClassNames('command-item-hint');
280
+ // Named by its label alone, so a screen reader says what it does before
281
+ // the rest, and described by the description and the hint.
282
+ const baseId = useId();
283
+ const labelId = `${baseId}label`;
284
+ const descriptionId = isShown(description)
285
+ ? `${baseId}description`
286
+ : undefined;
287
+ const hintId = isShown(hint) ? `${baseId}hint` : undefined;
288
+ const describedBy = [descriptionId, hintId].filter(Boolean).join(' ') || undefined;
289
+ const callerNamed = !!props['aria-label'] || 'aria-labelledby' in props;
290
+ return (jsxs("div", { className: classNames(itemClass, className), "aria-describedby": describedBy, ...props, ref: itemRef, id: engine.itemId(value) || undefined, role: "option", "aria-selected": disabled ? undefined : highlighted || undefined, "aria-disabled": disabled || undefined, "aria-labelledby": callerNamed ? props['aria-labelledby'] : labelId, onClick: event => {
291
+ onClick?.(event);
292
+ if (!disabled && !event.defaultPrevented)
293
+ engine.pick(value);
294
+ }, onPointerMove: event => {
295
+ onPointerMove?.(event);
296
+ if (!disabled && !highlighted)
297
+ engine.hover(value);
298
+ }, children: [isShown(icon) && (jsx("span", { className: iconClass, "aria-hidden": "true", children: icon })), jsxs("span", { className: bodyClass, children: [jsx("span", { className: labelClass, id: labelId, children: children }), descriptionId && (jsx("span", { className: descriptionClass, id: descriptionId, children: description }))] }), hintId && (jsx("span", { className: hintClass, id: hintId, children: hint }))] }));
299
+ };
300
+ CommandItem.displayName = 'CommandItem';
301
+ /**
302
+ * The message shown in place of the list when nothing matches, announced when typing brings it on screen.
303
+ *
304
+ * @function
305
+ * @param {CommandEmptyProps} props - Props for the Command.Empty component.
306
+ * @returns {JSX.Element} The message.
307
+ */
308
+ const CommandEmpty = ({ children, className, onMouseDown, ...props }) => {
309
+ const { engine, reportEmpty } = useCommand();
310
+ const emptyRef = useRef(null);
311
+ useClientLayoutEffect(() => reportEmpty(emptyRef.current), [reportEmpty]);
312
+ const emptyClass = usePrefixedClassNames('command-empty');
313
+ return (jsx("div", { className: classNames(emptyClass, className), ...props, ref: emptyRef, onMouseDown: event => {
314
+ onMouseDown?.(event);
315
+ engine.keepFocus(event);
316
+ }, children: children }));
317
+ };
318
+ CommandEmpty.displayName = 'CommandEmpty';
319
+ /**
320
+ * The field, and the actions that match the search, grouped, or the empty
321
+ * message. The search is the parts root's, read from its context.
322
+ */
323
+ const CommandActionsContent = ({ actions, filter = filterActions, placeholder, empty, }) => {
324
+ const { query } = useCommand();
325
+ const shown = filter(query, actions);
326
+ // Each group where the matches first name it, and the actions with none
327
+ // together where the first of them is, so the best match stays on top.
328
+ const sections = new Map();
329
+ for (const action of shown) {
330
+ const group = action.group ?? '';
331
+ sections.set(group, [...(sections.get(group) ?? []), action]);
332
+ }
333
+ const renderItem = (action) => (jsx(CommandItem, { value: action.id, disabled: action.disabled, icon: action.icon, description: action.description, hint: action.hint, onSelect: action.onSelect, children: action.label }, action.id));
334
+ return (jsxs(Fragment, { children: [jsx(CommandInput, { placeholder: placeholder }), shown.length > 0 ? (jsx(CommandList, { children: Array.from(sections, ([group, members]) => group ? (jsx(CommandGroup, { label: group, children: members.map(renderItem) }, group)) : (members.map(renderItem))) })) : isShown(empty) ? (jsx(CommandEmpty, { children: empty })) : null] }));
335
+ };
336
+ /**
337
+ * The Command given `actions`. It renders the parts itself and filters the
338
+ * actions as the user types, so a run goes through the same parts, keys and
339
+ * ARIA as one built by hand.
340
+ */
341
+ const CommandFromActions = forwardRef(({ actions, filter, placeholder, empty = defaultEmpty, children, ...rest }, ref) => {
342
+ if (flattenChildren(children).length > 0) {
343
+ warnOnce('Command:actions-children', '[bestax-bulma] <Command actions> renders its own field and items, ' +
344
+ 'so its children are ignored. Give it actions or build it from ' +
345
+ 'parts, not both.');
346
+ }
347
+ return (jsx(CommandParts, { ...rest, ref: ref, children: jsx(CommandActionsContent, { actions: actions, filter: filter, placeholder: placeholder, empty: empty }) }));
348
+ });
349
+ CommandFromActions.displayName = 'CommandFromActions';
350
+ /**
351
+ * The `Command` component is a search field over a list of things to do, which narrows as you type and runs the top match on Enter: inline by default, and a command palette in a modal as `Command.Dialog`.
352
+ *
353
+ * Given `actions`, it's one element. It renders the field, the list, the
354
+ * groups and the empty message, and filters the actions by label, keywords
355
+ * and description as the user types, with label matches first. `filter`
356
+ * takes over the matching, for fuzzy search.
357
+ *
358
+ * Built from parts, the app owns the items: what's in the list and how it's
359
+ * filtered, from the search `onQueryChange` reports. Nothing filters them,
360
+ * so a server's answer shows as it came.
361
+ *
362
+ * Either way, focus stays in the field, and the highlighted item is its
363
+ * `aria-activedescendant`. The first enabled item is highlighted whenever
364
+ * the items change, so Enter runs the top match. Running an item calls its
365
+ * own `onSelect`, then the Command's.
366
+ *
367
+ * When typing leaves the empty message on screen, its text is announced
368
+ * through a polite status region, once, after any loading ends. Result
369
+ * counts aren't announced: a screen reader reads the list's size from the
370
+ * markup, and a count on every keystroke talks over the typing.
371
+ *
372
+ * @function
373
+ * @param {CommandProps} props - Props for the Command component.
374
+ * @param {React.Ref<HTMLDivElement>} ref - Forwarded ref to the `div.command`.
375
+ * @returns {JSX.Element} The field and its list.
376
+ *
377
+ * @example
378
+ * <Command
379
+ * label="Settings"
380
+ * placeholder="Search settings"
381
+ * actions={[
382
+ * { id: 'profile', label: 'Edit profile', onSelect: openProfile },
383
+ * { id: 'theme', label: 'Dark mode', keywords: ['theme'], onSelect: toggleTheme },
384
+ * ]}
385
+ * />
386
+ *
387
+ * @example
388
+ * <Command label="People" onQueryChange={setQuery} onSelect={openProfile}>
389
+ * <Command.Input placeholder="Find a person" />
390
+ * {matches.length > 0 ? (
391
+ * <Command.List>
392
+ * {matches.map(person => (
393
+ * <Command.Item key={person.id} value={person.id}>
394
+ * {person.name}
395
+ * </Command.Item>
396
+ * ))}
397
+ * </Command.List>
398
+ * ) : (
399
+ * <Command.Empty>No one matches</Command.Empty>
400
+ * )}
401
+ * </Command>
402
+ */
403
+ const CommandRoot = forwardRef((props, ref) => props.actions === undefined ? (jsx(CommandParts, { ...props, ref: ref })) : (jsx(CommandFromActions, { ...props, ref: ref })));
404
+ /** Other names a key goes by in a `shortcut`, for the ones `key` spells otherwise. */
405
+ const keyAliases = {
406
+ space: ' ',
407
+ esc: 'escape',
408
+ };
409
+ /**
410
+ * Reads a `shortcut` such as `"mod+k"` or `"ctrl + shift + p"`.
411
+ *
412
+ * @param shortcut - Modifiers and a key, joined by `+`.
413
+ * @returns The combination, or `null` when it doesn't read as one.
414
+ */
415
+ const parseShortcut = (shortcut) => {
416
+ const parts = shortcut
417
+ .toLowerCase()
418
+ .split('+')
419
+ .map(part => part.trim());
420
+ const key = parts.pop();
421
+ if (key === '')
422
+ return null;
423
+ const parsed = {
424
+ key: keyAliases[key] ?? key,
425
+ mod: false,
426
+ ctrl: false,
427
+ meta: false,
428
+ alt: false,
429
+ shift: false,
430
+ };
431
+ for (const part of parts) {
432
+ if (part === 'mod')
433
+ parsed.mod = true;
434
+ else if (part === 'ctrl' || part === 'control')
435
+ parsed.ctrl = true;
436
+ else if (part === 'meta' || part === 'cmd' || part === 'command')
437
+ parsed.meta = true;
438
+ else if (part === 'alt' || part === 'option')
439
+ parsed.alt = true;
440
+ else if (part === 'shift')
441
+ parsed.shift = true;
442
+ else
443
+ return null;
444
+ }
445
+ return parsed;
446
+ };
447
+ /**
448
+ * Whether a keydown is the combination. `mod` takes Ctrl or ⌘, and any other
449
+ * modifier has to be as the combination says, so `mod+k` isn't
450
+ * `mod+shift+k`. A letter or digit is matched by its key, and by where it
451
+ * sits on the keyboard only when the key isn't a Latin letter or digit at
452
+ * all, as on a Cyrillic layout or with Option held on a Mac, so another
453
+ * Latin layout's own letters keep their own shortcuts.
454
+ */
455
+ const matchesShortcut = (event, shortcut) => {
456
+ if (event.altKey !== shortcut.alt)
457
+ return false;
458
+ // A symbol such as `?` already says whether Shift was down, and which key
459
+ // takes Shift to type it depends on the layout, so Shift is only checked
460
+ // for a letter, a digit or a named key, unless the combination names it.
461
+ const shiftMatters = shortcut.shift ||
462
+ shortcut.key.length > 1 ||
463
+ /^[a-z0-9]$/.test(shortcut.key);
464
+ if (shiftMatters && event.shiftKey !== shortcut.shift)
465
+ return false;
466
+ if (shortcut.mod) {
467
+ if (!event.ctrlKey && !event.metaKey)
468
+ return false;
469
+ }
470
+ else if (event.ctrlKey !== shortcut.ctrl ||
471
+ event.metaKey !== shortcut.meta) {
472
+ return false;
473
+ }
474
+ const key = (event.key ?? '').toLowerCase();
475
+ if (key === shortcut.key)
476
+ return true;
477
+ if (/^[a-z0-9]$/.test(key))
478
+ return false;
479
+ if (/^[a-z]$/.test(shortcut.key)) {
480
+ return event.code === `Key${shortcut.key.toUpperCase()}`;
481
+ }
482
+ if (/^[0-9]$/.test(shortcut.key)) {
483
+ return event.code === `Digit${shortcut.key}`;
484
+ }
485
+ return false;
486
+ };
487
+ /** Input types that take no typing, so a shortcut pressed in one still counts. */
488
+ const untypedInputs = new Set([
489
+ 'button',
490
+ 'checkbox',
491
+ 'color',
492
+ 'file',
493
+ 'hidden',
494
+ 'image',
495
+ 'radio',
496
+ 'range',
497
+ 'reset',
498
+ 'submit',
499
+ ]);
500
+ /**
501
+ * Whether a keydown happened while the user was typing: in a text field, a
502
+ * text area, a select, or editable content. The composed path's first entry
503
+ * is the element itself, even inside a shadow root.
504
+ */
505
+ const isTyping = (event) => {
506
+ const target = event.composedPath()[0];
507
+ if (typeof target?.closest !== 'function')
508
+ return false;
509
+ const element = target;
510
+ const editable = element.closest('[contenteditable]');
511
+ if (editable && editable.getAttribute('contenteditable') !== 'false') {
512
+ return true;
513
+ }
514
+ const tag = element.tagName;
515
+ if (tag === 'TEXTAREA' || tag === 'SELECT')
516
+ return true;
517
+ return (tag === 'INPUT' &&
518
+ !untypedInputs.has(element.type.toLowerCase()));
519
+ };
520
+ /**
521
+ * The Command in a modal, a command palette, opened by its `shortcut` or by `open`, that closes once an item has run.
522
+ *
523
+ * It renders nothing while it's closed. Open, it's a `Modal` with a
524
+ * background and the Command's box as the `dialog`, named by `label`:
525
+ * focus goes to the search field, Tab stays inside, Escape and a click on
526
+ * the background close it, the page behind it doesn't scroll, and focus goes
527
+ * back to where it was when it closes.
528
+ *
529
+ * @function
530
+ * @param {CommandDialogProps} props - Props for the Command.Dialog component.
531
+ * @param {React.Ref<HTMLDivElement>} ref - Forwarded ref to the `div.command` that is the dialog.
532
+ * @returns {JSX.Element | null} The dialog, or nothing while it's closed.
533
+ */
534
+ const CommandDialog = forwardRef(({ open: controlledOpen, defaultOpen = false, onOpenChange, shortcut, closeOnSelect = true, clearOnClose = true, portal, label, query: controlledQuery, defaultQuery = '', onQueryChange, onSelect, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...props }, ref) => {
535
+ const [ownOpen, setOwnOpen] = useState(defaultOpen);
536
+ const open = controlledOpen ?? ownOpen;
537
+ // Only ever asked for the other state: the shortcut listens while it's
538
+ // closed, and everything that closes it is there while it's open.
539
+ const setOpen = (next) => {
540
+ if (controlledOpen === undefined)
541
+ setOwnOpen(next);
542
+ onOpenChange?.(next);
543
+ };
544
+ // The search is held here rather than in the Command, which is only
545
+ // mounted while the dialog is open, so it can outlive a close when
546
+ // `clearOnClose` is off.
547
+ const [ownQuery, setOwnQuery] = useState(defaultQuery);
548
+ const query = controlledQuery ?? ownQuery;
549
+ const setQuery = (next) => {
550
+ if (controlledQuery === undefined)
551
+ setOwnQuery(next);
552
+ onQueryChange?.(next);
553
+ };
554
+ // Closing clears the search, however the dialog closed. Our own is
555
+ // cleared during render, React's pattern for adjusting state when other
556
+ // state changes, and a controlled one is asked for after the commit.
557
+ const [shownOpen, setShownOpen] = useState(open);
558
+ if (shownOpen !== open) {
559
+ setShownOpen(open);
560
+ if (!open && clearOnClose && controlledQuery === undefined) {
561
+ setOwnQuery('');
562
+ }
563
+ }
564
+ const wasOpenRef = useRef(open);
565
+ const clearRef = useLatestRef(() => {
566
+ if (clearOnClose && controlledQuery !== undefined && query !== '') {
567
+ onQueryChange?.('');
568
+ }
569
+ });
570
+ useEffect(() => {
571
+ const wasOpen = wasOpenRef.current;
572
+ wasOpenRef.current = open;
573
+ if (wasOpen && !open)
574
+ clearRef.current();
575
+ }, [open, clearRef]);
576
+ if (controlledOpen === undefined && !defaultOpen && !shortcut) {
577
+ warnOnce('Command.Dialog:no-opener', '[bestax-bulma] <Command.Dialog> has no way to open. Give it a ' +
578
+ 'shortcut, or open and onOpenChange.');
579
+ }
580
+ if (shortcut && !parseShortcut(shortcut)) {
581
+ warnOnce(`Command.Dialog:shortcut:${shortcut}`, `[bestax-bulma] <Command.Dialog> can't read the shortcut ` +
582
+ `"${shortcut}". Write modifiers and a key joined by +, such as ` +
583
+ `"mod+k", where the modifiers are mod, ctrl, meta, alt and shift.`);
584
+ }
585
+ const openFromShortcut = useLatestRef(() => setOpen(true));
586
+ useEffect(() => {
587
+ const combination = shortcut ? parseShortcut(shortcut) : null;
588
+ if (!combination || open)
589
+ return undefined;
590
+ const onKeyDown = (event) => {
591
+ if (event.defaultPrevented ||
592
+ event.isComposing ||
593
+ event.keyCode === 229 ||
594
+ !matchesShortcut(event, combination) ||
595
+ isTyping(event) ||
596
+ // A Modal, a Dialog, a Sidebar with its overlay or a full-page
597
+ // Loading has the page shut off, so the key isn't the page's. A
598
+ // Sidebar without one leaves the page in use, so it still opens.
599
+ isModalOverlayOpen()) {
600
+ return;
601
+ }
602
+ // The browser's own use of the combination, such as Ctrl+K's search
603
+ // in the address bar, doesn't happen as well.
604
+ event.preventDefault();
605
+ openFromShortcut.current();
606
+ };
607
+ // On `window`, where a keydown arrives last, so anything on the page
608
+ // that used the key first has said so with `preventDefault()`.
609
+ window.addEventListener('keydown', onKeyDown);
610
+ return () => window.removeEventListener('keydown', onKeyDown);
611
+ }, [shortcut, open, openFromShortcut]);
612
+ const dialogClass = usePrefixedClassNames('command-dialog');
613
+ if (!open)
614
+ return null;
615
+ const close = () => setOpen(false);
616
+ const run = (value) => {
617
+ onSelect?.(value);
618
+ if (closeOnSelect)
619
+ close();
620
+ };
621
+ return (jsxs(Modal, { isActive: true, onClose: close, role: "presentation", portal: portal, children: [jsx(Modal.Background, { onClick: close }), jsx(CommandRoot, { ...props, ref: ref, className: classNames(dialogClass, className), label: label, query: query, onQueryChange: setQuery, onSelect: run, role: "dialog", "aria-modal": "true", "aria-label": ariaLabelledBy === undefined
622
+ ? ariaLabel || label || defaultLabel
623
+ : ariaLabel, "aria-labelledby": ariaLabelledBy })] }));
624
+ });
625
+ CommandDialog.displayName = 'CommandDialog';
626
+ /** Command with Input, List, Group, Item, Empty and Dialog sub-components. */
627
+ const Command = withSubComponents(CommandRoot, {
628
+ Input: CommandInput,
629
+ List: CommandList,
630
+ Group: CommandGroup,
631
+ Item: CommandItem,
632
+ Empty: CommandEmpty,
633
+ Dialog: CommandDialog,
634
+ }, 'Command');
635
+
636
+ export { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList };
637
+ //# sourceMappingURL=Command.js.map