@box/blueprint-web 17.3.1 → 17.4.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/lib-esm/combobox/chips-group.js +40 -23
- package/dist/lib-esm/combobox/combobox.js +56 -15
- package/dist/lib-esm/combobox/combobox.module.js +1 -1
- package/dist/lib-esm/combobox/utils/use-exiting-chip-ids.d.ts +10 -0
- package/dist/lib-esm/combobox/utils/use-exiting-chip-ids.js +33 -0
- package/dist/lib-esm/components.css +151 -77
- package/dist/lib-esm/index.css +151 -77
- package/dist/lib-esm/input-chip/input-chip.js +45 -9
- package/dist/lib-esm/input-chip/input-chip.module.js +1 -1
- package/dist/lib-esm/input-chip/types.d.ts +16 -0
- package/dist/lib-esm/input-chip/utils/use-input-chip-presence.d.ts +24 -0
- package/dist/lib-esm/input-chip/utils/use-input-chip-presence.js +54 -0
- package/package.json +3 -3
|
@@ -10,19 +10,37 @@ const ChipsGroup = props => {
|
|
|
10
10
|
className,
|
|
11
11
|
...rest
|
|
12
12
|
} = props;
|
|
13
|
-
|
|
14
|
-
// Used to store each InputChip reference
|
|
13
|
+
// Used to store each interactive InputChip reference
|
|
15
14
|
const chipsRef = useRef([]);
|
|
16
|
-
// Used to keep track of the current focused InputChip child
|
|
15
|
+
// Used to keep track of the current focused interactive InputChip child
|
|
17
16
|
const [focusIndex, setFocusIndex] = useState(0);
|
|
18
|
-
const
|
|
19
|
-
|
|
17
|
+
const childArray = React__default.Children.toArray(children);
|
|
18
|
+
// Map from each interactive child's position in `childArray` to its index among interactive
|
|
19
|
+
// children only. Chips with `present === false` are mid-exit-fade: they still occupy a slot
|
|
20
|
+
// in `childArray` (so their InputChip instance stays mounted, see use-input-chip-presence.ts),
|
|
21
|
+
// but they're excluded from focus/keyboard-nav bookkeeping since they're non-interactive.
|
|
22
|
+
const interactiveIndices = [];
|
|
23
|
+
childArray.forEach((child, index) => {
|
|
24
|
+
if (! /*#__PURE__*/React__default.isValidElement(child)) {
|
|
25
|
+
throw Error(`Element ${child} is not a valid React element`);
|
|
26
|
+
}
|
|
27
|
+
if (child.props.present === false) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!child.props.onDelete) {
|
|
31
|
+
throw Error(`Input chip must have an onDelete prop.`);
|
|
32
|
+
}
|
|
33
|
+
interactiveIndices.push(index);
|
|
34
|
+
});
|
|
35
|
+
const interactiveCount = interactiveIndices.length;
|
|
36
|
+
const setFocus = interactiveIndex => {
|
|
37
|
+
chipsRef.current?.[interactiveIndex]?.focus();
|
|
20
38
|
};
|
|
21
39
|
const handleKeyDown = event => {
|
|
22
40
|
const focusNextChip = () => setFocus(focusIndex + 1);
|
|
23
41
|
const focusPrevChip = () => setFocus(focusIndex - 1);
|
|
24
42
|
const focusFirstChip = () => setFocus(0);
|
|
25
|
-
const focusLastChip = () => setFocus(
|
|
43
|
+
const focusLastChip = () => setFocus(interactiveCount - 1);
|
|
26
44
|
// Define the action for each supported keyboard code
|
|
27
45
|
const codeToActionMap = {
|
|
28
46
|
ArrowRight: focusNextChip,
|
|
@@ -37,12 +55,12 @@ const ChipsGroup = props => {
|
|
|
37
55
|
codeToActionMap[event.code]();
|
|
38
56
|
}
|
|
39
57
|
};
|
|
40
|
-
const handleDelete =
|
|
58
|
+
const handleDelete = interactiveIndex => {
|
|
41
59
|
// Remove chip reference
|
|
42
|
-
chipsRef.current.splice(
|
|
60
|
+
chipsRef.current.splice(interactiveIndex, 1);
|
|
43
61
|
// Compute index of the next chip to focus
|
|
44
|
-
const hasDeletedLastChip =
|
|
45
|
-
const newFocusIndex = hasDeletedLastChip ?
|
|
62
|
+
const hasDeletedLastChip = interactiveIndex === interactiveCount - 1;
|
|
63
|
+
const newFocusIndex = hasDeletedLastChip ? interactiveIndex - 1 : interactiveIndex;
|
|
46
64
|
// Focus next chip
|
|
47
65
|
setFocus(newFocusIndex);
|
|
48
66
|
setFocusIndex(newFocusIndex);
|
|
@@ -51,14 +69,16 @@ const ChipsGroup = props => {
|
|
|
51
69
|
...rest,
|
|
52
70
|
className: clsx(styles.chipsGroup, className),
|
|
53
71
|
role: "grid",
|
|
54
|
-
children:
|
|
72
|
+
children: childArray.map((child, index) => {
|
|
55
73
|
if (! /*#__PURE__*/React__default.isValidElement(child)) {
|
|
56
74
|
throw Error(`Element ${child} is not a valid React element`);
|
|
57
75
|
}
|
|
58
|
-
if (!child.props.onDelete) {
|
|
59
|
-
throw Error(`Input chip must have an onDelete prop.`);
|
|
60
|
-
}
|
|
61
76
|
const childElement = child;
|
|
77
|
+
const interactiveIndex = interactiveIndices.indexOf(index);
|
|
78
|
+
const isExiting = interactiveIndex === -1;
|
|
79
|
+
// Always wrap in the same <Tooltip> shape (interactive AND exiting) so a chip's
|
|
80
|
+
// <InputChip> stays at a structurally stable tree position across the
|
|
81
|
+
// interactive->exiting transition to avoid remounts.
|
|
62
82
|
return jsx("div", {
|
|
63
83
|
role: "row",
|
|
64
84
|
children: jsx("div", {
|
|
@@ -68,11 +88,11 @@ const ChipsGroup = props => {
|
|
|
68
88
|
, {
|
|
69
89
|
"aria-hidden": true,
|
|
70
90
|
content: childElement.props.tooltip ?? childElement.props.label,
|
|
71
|
-
children: /*#__PURE__*/React__default.cloneElement(childElement, {
|
|
91
|
+
children: isExiting ? childElement : /*#__PURE__*/React__default.cloneElement(childElement, {
|
|
72
92
|
// Register the InputChip reference
|
|
73
93
|
ref: node => {
|
|
74
94
|
if (node) {
|
|
75
|
-
chipsRef.current[
|
|
95
|
+
chipsRef.current[interactiveIndex] = node;
|
|
76
96
|
}
|
|
77
97
|
},
|
|
78
98
|
// Unset value added by Tooltip so that screen readers don't read the label twice
|
|
@@ -82,18 +102,15 @@ const ChipsGroup = props => {
|
|
|
82
102
|
// We know childElement will always have an onDelete function because of the error
|
|
83
103
|
// above. Optional chaining is to make typescript happy
|
|
84
104
|
childElement.props.onDelete?.();
|
|
85
|
-
handleDelete(
|
|
105
|
+
handleDelete(interactiveIndex);
|
|
86
106
|
},
|
|
87
|
-
// Handle keyboard navigation
|
|
88
107
|
onKeyDown: handleKeyDown,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
// Only one of the InputChip contained by the grid is included in the page tab sequence
|
|
92
|
-
tabIndex: focusIndex === index ? 0 : -1
|
|
108
|
+
onFocus: () => setFocusIndex(interactiveIndex),
|
|
109
|
+
tabIndex: focusIndex === interactiveIndex ? 0 : -1
|
|
93
110
|
})
|
|
94
111
|
})
|
|
95
112
|
})
|
|
96
|
-
});
|
|
113
|
+
}, childElement.key);
|
|
97
114
|
})
|
|
98
115
|
});
|
|
99
116
|
};
|
|
@@ -5,7 +5,10 @@ import { Checkmark } from '@box/blueprint-web-assets/icons/Fill';
|
|
|
5
5
|
import { AlertCircle, XMark } from '@box/blueprint-web-assets/icons/Medium';
|
|
6
6
|
import { bpIconIconOnLight, IconIconErrorOnLight } from '@box/blueprint-web-assets/tokens/tokens';
|
|
7
7
|
import clsx from 'clsx';
|
|
8
|
-
import React__default, { forwardRef, useMemo, useRef, useCallback, useEffect } from 'react';
|
|
8
|
+
import React__default, { forwardRef, useMemo, useRef, useCallback, useEffect, useState } from 'react';
|
|
9
|
+
import '../blueprint-configuration-context/blueprint-configuration-context.js';
|
|
10
|
+
import '../blueprint-configuration-context/consts.js';
|
|
11
|
+
import { useBlueprintConfiguration } from '../blueprint-configuration-context/useBlueprintConfiguration.js';
|
|
9
12
|
import { Focusable } from '../focusable/focusable.js';
|
|
10
13
|
import { InputChip } from '../input-chip/input-chip.js';
|
|
11
14
|
import { LoadingIndicator } from '../loading-indicator/loading-indicator.js';
|
|
@@ -14,12 +17,14 @@ import { InlineError } from '../primitives/inline-error/inline-error.js';
|
|
|
14
17
|
import { TextArea } from '../text-area/text-area.js';
|
|
15
18
|
import { Tooltip } from '../tooltip/tooltip.js';
|
|
16
19
|
import { useLabelable } from '../util-components/labelable/useLabelable.js';
|
|
17
|
-
import { VisuallyHidden } from '../visually-hidden/visually-hidden.js';
|
|
18
20
|
import { composeEventHandlers } from '../utils/composeEventHandlers.js';
|
|
21
|
+
import { useEnhancedEffect } from '../utils/useEnhancedEffect.js';
|
|
19
22
|
import { useForkRef } from '../utils/useForkRef.js';
|
|
20
23
|
import { useUniqueId } from '../utils/useUniqueId.js';
|
|
24
|
+
import { VisuallyHidden } from '../visually-hidden/visually-hidden.js';
|
|
21
25
|
import { ChipsGroup } from './chips-group.js';
|
|
22
26
|
import styles from './combobox.module.js';
|
|
27
|
+
import { useExitingChipIds } from './utils/use-exiting-chip-ids.js';
|
|
23
28
|
|
|
24
29
|
const getOptionValue = option => typeof option === 'string' ? option : option.value;
|
|
25
30
|
const getOptionFromValue = (value, options) => options.find(option => typeof option === 'string' ? option === value : option.value === value);
|
|
@@ -100,6 +105,10 @@ const RootInner = ({
|
|
|
100
105
|
getPopoverRef?.(node);
|
|
101
106
|
}, [getPopoverRef]);
|
|
102
107
|
const hasError = !!error && !disabled;
|
|
108
|
+
const {
|
|
109
|
+
componentsWithAnimationEnabled
|
|
110
|
+
} = useBlueprintConfiguration();
|
|
111
|
+
const isAnimationEnabled = componentsWithAnimationEnabled.includes('Combobox');
|
|
103
112
|
const comboboxStore = useComboboxStore({
|
|
104
113
|
// Input state
|
|
105
114
|
defaultValue: defaultInputValue,
|
|
@@ -119,6 +128,10 @@ const RootInner = ({
|
|
|
119
128
|
} = comboboxStore;
|
|
120
129
|
const inputValue = comboboxStore.useState('value');
|
|
121
130
|
const isOpen = comboboxStore.useState('open');
|
|
131
|
+
// Drives the direction of the open/close slide animation so a popover that
|
|
132
|
+
// flips above the input slides up instead of down. See combobox.module.scss.
|
|
133
|
+
const currentPlacement = comboboxStore.useState('currentPlacement');
|
|
134
|
+
const popoverSide = currentPlacement?.split('-')[0];
|
|
122
135
|
const getSelectedOptionValues = useCallback(valueSource => {
|
|
123
136
|
return Array.isArray(valueSource) ? valueSource.map(val => getOptionValue(val)) : getOptionValue(valueSource);
|
|
124
137
|
}, []);
|
|
@@ -332,9 +345,28 @@ const RootInner = ({
|
|
|
332
345
|
focusInput();
|
|
333
346
|
};
|
|
334
347
|
const reference = useForkRef(inputRef, ref);
|
|
335
|
-
const showChipsGroup = Array.isArray(selectedValue) && selectedValue.length > 0;
|
|
336
348
|
const showComboboxCancelButton = clearButtonAriaLabel && (inputValue.length > 0 || (Array.isArray(selectedValue) ? selectedValue.length > 0 : !!selectedValue));
|
|
337
349
|
const showSingleSelectChip = displaySingleSelectionAsChip && !Array.isArray(selectedValue) && !!selectedValue;
|
|
350
|
+
const singleSelectValue = Array.isArray(selectedValue) ? '' : selectedValue;
|
|
351
|
+
const multiSelectIds = useMemo(() => Array.isArray(selectedValue) ? selectedValue : [], [selectedValue]);
|
|
352
|
+
const {
|
|
353
|
+
renderedIds: renderedMultiSelectIds,
|
|
354
|
+
handleExited: handleMultiSelectChipExited
|
|
355
|
+
} = useExitingChipIds(multiSelectIds, isAnimationEnabled);
|
|
356
|
+
const showChipsGroup = renderedMultiSelectIds.length > 0;
|
|
357
|
+
// track that the single-select chip's layout class (`.withChips`) should
|
|
358
|
+
// stay applied while it's mid-exit-fade. `InputChip` owns the actual exiting content/state.
|
|
359
|
+
const [isSingleSelectChipExiting, setIsSingleSelectChipExiting] = useState(false);
|
|
360
|
+
const prevShowSingleSelectChipRef = useRef(showSingleSelectChip);
|
|
361
|
+
useEnhancedEffect(() => {
|
|
362
|
+
if (isAnimationEnabled && prevShowSingleSelectChipRef.current && !showSingleSelectChip) {
|
|
363
|
+
setIsSingleSelectChipExiting(true);
|
|
364
|
+
}
|
|
365
|
+
prevShowSingleSelectChipRef.current = showSingleSelectChip;
|
|
366
|
+
}, [showSingleSelectChip, isAnimationEnabled]);
|
|
367
|
+
const handleSingleSelectChipAnimationEnd = useCallback(() => {
|
|
368
|
+
setIsSingleSelectChipExiting(false);
|
|
369
|
+
}, []);
|
|
338
370
|
const showInlineError = errorVariant === 'inline' && hasError;
|
|
339
371
|
const InlineErrorIcon = AlertCircle ;
|
|
340
372
|
const Label = useLabelable(label, comboboxId, required);
|
|
@@ -362,11 +394,12 @@ const RootInner = ({
|
|
|
362
394
|
ref: comboboxContainerRef,
|
|
363
395
|
className: clsx(styles.comboboxContainer, {
|
|
364
396
|
[styles.error]: hasError,
|
|
365
|
-
[styles.withChips]: showChipsGroup || showSingleSelectChip,
|
|
397
|
+
[styles.withChips]: showChipsGroup || showSingleSelectChip || isSingleSelectChipExiting,
|
|
366
398
|
[styles.withComboboxButtons]: showComboboxCancelButton || Boolean(endComboboxIcon) || showInlineError,
|
|
367
399
|
[styles.withInlineErrorAndEndButton]: showInlineError && (showComboboxCancelButton || Boolean(endComboboxIcon))
|
|
368
400
|
}),
|
|
369
401
|
onClick: handleFocusInputOnEvent,
|
|
402
|
+
"data-bp-animated": isAnimationEnabled ? 'true' : 'false',
|
|
370
403
|
"data-testid": "combobox-container",
|
|
371
404
|
children: jsxs("div", {
|
|
372
405
|
className: styles.comboboxContainerInner,
|
|
@@ -374,18 +407,24 @@ const RootInner = ({
|
|
|
374
407
|
role: "presentation",
|
|
375
408
|
children: [showChipsGroup && jsx(ChipsGroup, {
|
|
376
409
|
onClick: handleFocusInputOnEvent,
|
|
377
|
-
children:
|
|
378
|
-
avatar: getDisplayAvatarFromOptionValue(
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
410
|
+
children: renderedMultiSelectIds.map(id => jsx(InputChip, {
|
|
411
|
+
avatar: getDisplayAvatarFromOptionValue(id, options, displayAvatar),
|
|
412
|
+
isAnimationEnabled: isAnimationEnabled,
|
|
413
|
+
label: getDisplayValueFromOptionValue(id, options, displayValue),
|
|
414
|
+
onAnimationEnd: () => handleMultiSelectChipExited(id),
|
|
415
|
+
onDelete: () => removeMultiSelectInputChip(id),
|
|
416
|
+
present: selectedValue.includes(id),
|
|
417
|
+
tooltip: getTooltipValueFromOptionValue(id, options, displayTooltip),
|
|
418
|
+
variant: getDisplayChipVariantFromOptionValue(id, options, displayChipVariant)
|
|
419
|
+
}, id))
|
|
420
|
+
}), jsx(InputChip, {
|
|
421
|
+
isAnimationEnabled: isAnimationEnabled,
|
|
422
|
+
label: getDisplayValueFromOptionValue(singleSelectValue, options, displayValue),
|
|
423
|
+
onAnimationEnd: handleSingleSelectChipAnimationEnd,
|
|
386
424
|
onDelete: showComboboxCancelButton ? undefined : removeSingleSelectInputChip,
|
|
387
|
-
|
|
388
|
-
|
|
425
|
+
present: showSingleSelectChip,
|
|
426
|
+
tooltip: getTooltipValueFromOptionValue(singleSelectValue, options, displayTooltip),
|
|
427
|
+
variant: getDisplayChipVariantFromOptionValue(singleSelectValue, options, displayChipVariant)
|
|
389
428
|
}), jsxs("div", {
|
|
390
429
|
className: styles.textInputWrapper,
|
|
391
430
|
children: [jsx(Combobox$1, {
|
|
@@ -485,7 +524,9 @@ const RootInner = ({
|
|
|
485
524
|
ref: popoverRefCallback,
|
|
486
525
|
"aria-labelledby": comboboxId,
|
|
487
526
|
className: clsx(styles.popover, popoverClassName),
|
|
527
|
+
"data-bp-animated": isAnimationEnabled ? 'true' : 'false',
|
|
488
528
|
"data-modern": 'true' ,
|
|
529
|
+
"data-side": popoverSide,
|
|
489
530
|
fitViewport: true,
|
|
490
531
|
gutter: 8,
|
|
491
532
|
hideOnEscape: hideOnEscape,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import '../index.css';
|
|
2
|
-
var styles = {"container":"bp_combobox_module_container--
|
|
2
|
+
var styles = {"container":"bp_combobox_module_container--e892e","label":"bp_combobox_module_label--e892e","textInput":"bp_combobox_module_textInput--e892e","popover":"bp_combobox_module_popover--e892e","disabled":"bp_combobox_module_disabled--e892e","hiddenLabel":"bp_combobox_module_hiddenLabel--e892e","comboboxContainer":"bp_combobox_module_comboboxContainer--e892e","comboboxContainerInner":"bp_combobox_module_comboboxContainerInner--e892e","withChips":"bp_combobox_module_withChips--e892e","error":"bp_combobox_module_error--e892e","withComboboxButtons":"bp_combobox_module_withComboboxButtons--e892e","withInlineErrorAndEndButton":"bp_combobox_module_withInlineErrorAndEndButton--e892e","textInputWrapper":"bp_combobox_module_textInputWrapper--e892e","comboboxButtons":"bp_combobox_module_comboboxButtons--e892e","inlineErrorIcon":"bp_combobox_module_inlineErrorIcon--e892e","inlineError":"bp_combobox_module_inlineError--e892e","popoverInner":"bp_combobox_module_popoverInner--e892e","option":"bp_combobox_module_option--e892e","indicator":"bp_combobox_module_indicator--e892e","indicatorIcon":"bp_combobox_module_indicatorIcon--e892e","optionWithIndicator":"bp_combobox_module_optionWithIndicator--e892e","loadingIndicator":"bp_combobox_module_loadingIndicator--e892e"};
|
|
3
3
|
|
|
4
4
|
export { styles as default };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks which ids were just removed from `currentIds` so Combobox can keep rendering their
|
|
3
|
+
* <InputChip present={false}> at a stable tree position until its own exit animation finishes
|
|
4
|
+
* and calls back (via onAnimationEnd). InputChip owns exit timing + its own last-known
|
|
5
|
+
* content — this hook only tracks *which* ids still need a slot in the render list.
|
|
6
|
+
*/
|
|
7
|
+
export declare function useExitingChipIds(currentIds: string[], isAnimationEnabled: boolean): {
|
|
8
|
+
renderedIds: string[];
|
|
9
|
+
handleExited: (id: string) => void;
|
|
10
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tracks which ids were just removed from `currentIds` so Combobox can keep rendering their
|
|
5
|
+
* <InputChip present={false}> at a stable tree position until its own exit animation finishes
|
|
6
|
+
* and calls back (via onAnimationEnd). InputChip owns exit timing + its own last-known
|
|
7
|
+
* content — this hook only tracks *which* ids still need a slot in the render list.
|
|
8
|
+
*/
|
|
9
|
+
function useExitingChipIds(currentIds, isAnimationEnabled) {
|
|
10
|
+
const [exitingIds, setExitingIds] = useState([]);
|
|
11
|
+
const [prevIds, setPrevIds] = useState(currentIds);
|
|
12
|
+
if (currentIds !== prevIds) {
|
|
13
|
+
if (isAnimationEnabled) {
|
|
14
|
+
const currentIdSet = new Set(currentIds);
|
|
15
|
+
const newlyRemoved = prevIds.filter(id => !currentIdSet.has(id));
|
|
16
|
+
if (newlyRemoved.length > 0) {
|
|
17
|
+
setExitingIds(prev => Array.from(new Set([...prev, ...newlyRemoved])));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
setPrevIds(currentIds);
|
|
21
|
+
}
|
|
22
|
+
const handleExited = useCallback(id => {
|
|
23
|
+
setExitingIds(prev => prev.filter(existingId => existingId !== id));
|
|
24
|
+
}, []);
|
|
25
|
+
const stillExiting = exitingIds.filter(id => !currentIds.includes(id));
|
|
26
|
+
const renderedIds = isAnimationEnabled ? [...currentIds, ...stillExiting] : currentIds;
|
|
27
|
+
return {
|
|
28
|
+
renderedIds,
|
|
29
|
+
handleExited
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export { useExitingChipIds };
|