@box/blueprint-web 17.3.0 → 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.
@@ -10,19 +10,37 @@ const ChipsGroup = props => {
10
10
  className,
11
11
  ...rest
12
12
  } = props;
13
- const count = React__default.Children.count(children);
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 setFocus = index => {
19
- chipsRef.current?.[index]?.focus();
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(count - 1);
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 = index => {
58
+ const handleDelete = interactiveIndex => {
41
59
  // Remove chip reference
42
- chipsRef.current.splice(index, 1);
60
+ chipsRef.current.splice(interactiveIndex, 1);
43
61
  // Compute index of the next chip to focus
44
- const hasDeletedLastChip = index === count - 1;
45
- const newFocusIndex = hasDeletedLastChip ? index - 1 : index;
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: React__default.Children.map(children, (child, index) => {
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[index] = node;
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(index);
105
+ handleDelete(interactiveIndex);
86
106
  },
87
- // Handle keyboard navigation
88
107
  onKeyDown: handleKeyDown,
89
- // Update internal state to keep track of the current focused InputChip
90
- onFocus: () => setFocusIndex(index),
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: selectedValue.map(selected => jsx(InputChip, {
378
- avatar: getDisplayAvatarFromOptionValue(selected, options, displayAvatar),
379
- label: getDisplayValueFromOptionValue(selected, options, displayValue),
380
- onDelete: () => removeMultiSelectInputChip(selected),
381
- tooltip: getTooltipValueFromOptionValue(selected, options, displayTooltip),
382
- variant: getDisplayChipVariantFromOptionValue(selected, options, displayChipVariant)
383
- }, selected))
384
- }), showSingleSelectChip && jsx(InputChip, {
385
- label: getDisplayValueFromOptionValue(selectedValue, options, displayValue),
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
- tooltip: getTooltipValueFromOptionValue(selectedValue, options, displayTooltip),
388
- variant: getDisplayChipVariantFromOptionValue(selectedValue, options, displayChipVariant)
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--3ece2","label":"bp_combobox_module_label--3ece2","textInput":"bp_combobox_module_textInput--3ece2","popover":"bp_combobox_module_popover--3ece2","disabled":"bp_combobox_module_disabled--3ece2","hiddenLabel":"bp_combobox_module_hiddenLabel--3ece2","comboboxContainer":"bp_combobox_module_comboboxContainer--3ece2","comboboxContainerInner":"bp_combobox_module_comboboxContainerInner--3ece2","withChips":"bp_combobox_module_withChips--3ece2","error":"bp_combobox_module_error--3ece2","withComboboxButtons":"bp_combobox_module_withComboboxButtons--3ece2","withInlineErrorAndEndButton":"bp_combobox_module_withInlineErrorAndEndButton--3ece2","textInputWrapper":"bp_combobox_module_textInputWrapper--3ece2","comboboxButtons":"bp_combobox_module_comboboxButtons--3ece2","inlineErrorIcon":"bp_combobox_module_inlineErrorIcon--3ece2","inlineError":"bp_combobox_module_inlineError--3ece2","popoverInner":"bp_combobox_module_popoverInner--3ece2","option":"bp_combobox_module_option--3ece2","indicator":"bp_combobox_module_indicator--3ece2","indicatorIcon":"bp_combobox_module_indicatorIcon--3ece2","optionWithIndicator":"bp_combobox_module_optionWithIndicator--3ece2","loadingIndicator":"bp_combobox_module_loadingIndicator--3ece2"};
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 };