@moneyforward/mfui-components 4.6.0 → 4.7.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 (29) hide show
  1. package/dist/src/DataTable/DataTable.d.ts +1 -1
  2. package/dist/src/DataTable/DataTable.js +23 -3
  3. package/dist/src/DataTable/DataTableBody/DataTableBody.d.ts +2 -1
  4. package/dist/src/DataTable/DataTableBody/DataTableBody.js +72 -8
  5. package/dist/src/DataTable/DataTableBody/DataTableBody.types.d.ts +81 -1
  6. package/dist/src/DataTable/DataTableBody/hooks/useVirtualizedRows.d.ts +64 -0
  7. package/dist/src/DataTable/DataTableBody/hooks/useVirtualizedRows.js +53 -0
  8. package/dist/src/DataTable/DataTableBody/index.d.ts +1 -1
  9. package/dist/src/DataTable/DataTableHeader/DataTableHeader.js +13 -2
  10. package/dist/src/DataTable/DataTableHeaderRow/DataTableHeaderRow.js +6 -3
  11. package/dist/src/DataTable/DataTableHeaderRow/DataTableHeaderRow.types.d.ts +9 -0
  12. package/dist/src/DataTable/DataTableProvider.d.ts +29 -2
  13. package/dist/src/DataTable/DataTableProvider.js +20 -1
  14. package/dist/src/DataTable/index.d.ts +1 -1
  15. package/dist/src/MultipleSelectBox/MultipleSelectBox.js +12 -3
  16. package/dist/src/MultipleSelectBox/hooks/useOptionKeyboardNavigation.d.ts +1 -0
  17. package/dist/src/MultipleSelectBox/hooks/useOptionKeyboardNavigation.js +23 -16
  18. package/dist/src/SelectBox/SelectBox.js +12 -3
  19. package/dist/src/SelectBox/hooks/useKeyboardNavigation.d.ts +1 -0
  20. package/dist/src/SelectBox/hooks/useKeyboardNavigation.js +29 -14
  21. package/dist/src/Tooltip/Tooltip.d.ts +6 -0
  22. package/dist/src/Tooltip/Tooltip.js +6 -0
  23. package/dist/styled-system/recipes/data-table-body-slot-recipe.d.ts +1 -1
  24. package/dist/styled-system/recipes/data-table-body-slot-recipe.js +4 -0
  25. package/dist/styled-system/tokens/index.js +4 -0
  26. package/dist/styled-system/tokens/tokens.d.ts +1 -1
  27. package/dist/styles.css +9 -4
  28. package/dist/tsconfig.build.tsbuildinfo +1 -1
  29. package/package.json +1 -1
@@ -25,25 +25,31 @@ export function useOptionKeyboardNavigation({ filteredOptions, selectedValues, o
25
25
  // Fallback to 0 if no enabled option is found
26
26
  return Math.max(tabbableIndex, 0);
27
27
  }, [filteredOptions, selectedValues]);
28
- // State to track which option should be tabbable
29
- const [tabbableOptionIndex, setTabbableOptionIndex] = useState(getDefaultTabbableIndex());
30
- // Adjusted during render (React's documented pattern for resyncing state to changed
31
- // props) rather than in an effect, so the UI never flashes the stale index for one frame.
32
- const [prevFilteredOptions, setPrevFilteredOptions] = useState(filteredOptions);
33
- const [prevSelectedValues, setPrevSelectedValues] = useState(selectedValues);
34
- if (filteredOptions !== prevFilteredOptions || selectedValues !== prevSelectedValues) {
35
- setPrevFilteredOptions(filteredOptions);
36
- setPrevSelectedValues(selectedValues);
37
- setTabbableOptionIndex(getDefaultTabbableIndex());
38
- }
39
- // Update tabbable index when selected values change
28
+ // The focused option is stored by its value, and the tabbable index is calculated during render.
29
+ // Storing the index and resetting it whenever `filteredOptions` is a different array would set
30
+ // state during render, which loops forever when `filteredOptions` is a new array on every render
31
+ // (e.g. `options` is omitted). The focused option stays tabbable while it is in `filteredOptions`,
32
+ // including after it is toggled, so the tabbable option is always the focused one. The caller
33
+ // forgets it with `resetTabbableIndex` whenever the option panel opens, so every opening starts
34
+ // from the first selected option, or the first option when nothing is selected.
35
+ const [focusedOption, setFocusedOption] = useState(null);
36
+ const focusedOptionIndex = focusedOption
37
+ ? filteredOptions.findIndex((option) => isSelectableOption(option) && option.value === focusedOption.value)
38
+ : -1;
39
+ const tabbableOptionIndex = focusedOptionIndex !== -1 ? focusedOptionIndex : getDefaultTabbableIndex();
40
40
  const resetTabbableIndex = useCallback(() => {
41
- setTabbableOptionIndex(getDefaultTabbableIndex());
42
- }, [getDefaultTabbableIndex]);
41
+ setFocusedOption(null);
42
+ }, []);
43
43
  // Handler for when an option receives focus
44
44
  const handleOptionFocus = useCallback((optionIndex) => {
45
- setTabbableOptionIndex(optionIndex);
46
- }, []);
45
+ const option = filteredOptions[optionIndex];
46
+ if (!option || !isSelectableOption(option)) {
47
+ setFocusedOption(null);
48
+ return;
49
+ }
50
+ // Keep the current object when the same option is focused again, so React can skip the re-render.
51
+ setFocusedOption((previous) => previous && previous.value === option.value ? previous : { value: option.value });
52
+ }, [filteredOptions]);
47
53
  // Create keyboard event handler for an option
48
54
  const createKeyDownHandler = useCallback((option) => (event) => {
49
55
  const siblingElements = findSiblingElements(event.currentTarget, '[role="option"]:not([aria-disabled="true"])');
@@ -81,5 +87,6 @@ export function useOptionKeyboardNavigation({ filteredOptions, selectedValues, o
81
87
  tabbableOptionIndex,
82
88
  handleOptionFocus,
83
89
  createKeyDownHandler,
90
+ resetTabbableIndex,
84
91
  };
85
92
  }
@@ -33,12 +33,15 @@ const DEFAULT_SCROLL_OPTIONS = {
33
33
  };
34
34
  // How many skeleton items to render while the component is loading.
35
35
  const SKELETON_ITEM_COUNT = 4;
36
+ // The default for an omitted `options` prop. A default written inline (`options = []`) is a new
37
+ // array on every render, which invalidates every memoized value derived from the options.
38
+ const EMPTY_OPTIONS = [];
36
39
  /**
37
40
  * The general-purpose SelectBox component.
38
41
  * This component switches the variants of looks and behaviors depends on the props: size, invalid, caution, disabled.
39
42
  */
40
43
  export const SelectBox = forwardRef((props, ref) => {
41
- const { id, triggerProps, clearButtonProps, enableClearButton = false, size, options = [], defaultValue,
44
+ const { id, triggerProps, clearButtonProps, enableClearButton = false, size, options = EMPTY_OPTIONS, defaultValue,
42
45
  // eslint-disable-next-line @typescript-eslint/no-deprecated
43
46
  placeholder, emptyMessage, disabled, invalid, caution = false, required, targetDOMNode, name, onChange, onBlur, value, enableSearchOptions = false, notFoundMessage, searchBoxProps, loading = false, onSearchOptions, renderDisplayValue, renderOption, renderPopoverContent, enableAutoScrollToSelectedOption, popoverContentProps, popoverWrapperProps, onOpenStateChanged, showGroupOptionDivider, triggerWrapperProps, enableAutoUnmount = true, enableConstrainedPopoverWidth = false, enableVirtualization = false, virtualizationOptions, infiniteScroll, } = props;
44
47
  const classes = selectBoxSlotRecipe({ size, showGroupOptionDivider });
@@ -215,7 +218,7 @@ export const SelectBox = forwardRef((props, ref) => {
215
218
  });
216
219
  // Cleanup timeout on unmount
217
220
  useEffect(() => cleanup, [cleanup]);
218
- const { tabbableOptionIndex, handleOptionFocus, createKeyDownHandler } = useKeyboardNavigation({
221
+ const { tabbableOptionIndex, handleOptionFocus, createKeyDownHandler, resetTabbableIndex } = useKeyboardNavigation({
219
222
  isOptionPanelOpen,
220
223
  loading,
221
224
  enableSearchOptions,
@@ -384,6 +387,12 @@ export const SelectBox = forwardRef((props, ref) => {
384
387
  };
385
388
  const hasSelectedValue = value !== undefined || localSelectedOption !== null;
386
389
  const showClearButton = enableClearButton && hasSelectedValue;
390
+ const handleOpenStateChanged = (open) => {
391
+ // Every opening starts from the selected option, not from the option focused last time.
392
+ if (open)
393
+ resetTabbableIndex();
394
+ toggleOptionPanel();
395
+ };
387
396
  // Use Popover's centralized blur handling instead of custom logic
388
397
  return (_jsx(Popover, { open: isOptionPanelOpen, targetDOMNode: targetDOMNode, minWidth: popoverContentProps?.minWidth, maxHeight: popoverWrapperProps?.maxHeight, allowedPlacements: popoverContentProps?.allowedPlacements, enableConstrainedContentWidth: enableConstrainedPopoverWidth, renderContent: () => (_jsx("div", { ref: optionPanelRef, className: cx(classes.optionPanel, 'mfui-SelectBox__optionPanel', popoverContentProps?.className), tabIndex: -1, onKeyDown: handleKeyDownMenu, children: renderPopoverContent ? (renderPopoverContent({ searchNode, optionsNode })) : (_jsxs(_Fragment, { children: [searchNode, optionsNode] })) })), renderTrigger: ({ setTriggerRef, togglePopover, handleTriggerKeyDown, handleTriggerBlur }) => (_jsxs("div", { ref: setTriggerRef, ...triggerWrapperProps, className: cx(classes.triggerWrapper, 'mfui-SelectBox__triggerWrapper', triggerWrapperProps?.className), children: [_jsx(FocusIndicator, { children: _jsxs("button", { ref: triggerRef, id: id, type: "button", role: "combobox", disabled: disabled, "aria-label": triggerProps?.['aria-label'], "aria-controls": isOptionPanelOpen ? listBoxId : undefined, "aria-describedby": triggerProps?.['aria-describedby'], "aria-expanded": isOptionPanelOpen, "aria-haspopup": "listbox", "aria-invalid": invalid, "aria-required": required, className: cx(classes.trigger, 'mfui-SelectBox__trigger', triggerProps?.className), "data-placeholder": !!placeholder && !localSelectedOption, "data-selected": !!localSelectedOption, "data-mfui-has-clear-button": showClearButton, "data-mfui-caution": caution && !invalid ? true : undefined, onClick: togglePopover, onKeyDown: (event) => {
389
398
  handleTypeAhead(event.nativeEvent);
@@ -391,6 +400,6 @@ export const SelectBox = forwardRef((props, ref) => {
391
400
  }, onBlur: handleTriggerBlur, children: [_jsx("span", { "data-mfui-content": "select-box-trigger-display-value", children: renderTriggerLabel() }), _jsx(DropdownIcon, { "aria-hidden": "true", focusable: false }), _jsx("input", { ref: ref, type: "hidden", value: localSelectedOption?.value ?? '', name: name, disabled: disabled })] }) }), showClearButton ? (_jsx("div", { className: cx(classes.clearButtonWrapper, 'mfui-SelectBox__clearButtonWrapper'), children: _jsx(ClearButton, { size: size === 'small' ? 'small' : size === 'large' ? 'large' : 'default', "aria-label": clearButtonProps?.['aria-label'] ?? '値をクリアする', "data-mfui-content": "select-box-clear-button", onClick: (event) => {
392
401
  event.stopPropagation();
393
402
  handleClearValue();
394
- } }) })) : null] })), contentProps: { className: classes.popover }, enableAutoUnmount: enableAutoUnmount, onOpenStateChanged: toggleOptionPanel, onBlur: onBlur }));
403
+ } }) })) : null] })), contentProps: { className: classes.popover }, enableAutoUnmount: enableAutoUnmount, onOpenStateChanged: handleOpenStateChanged, onBlur: onBlur }));
395
404
  });
396
405
  SelectBox.displayName = 'SelectBox';
@@ -73,5 +73,6 @@ export declare function useKeyboardNavigation<T extends string | number | undefi
73
73
  tabbableOptionIndex: number;
74
74
  handleOptionFocus: (optionIndex: number) => void;
75
75
  createKeyDownHandler: (option: SelectBoxOption<T, AdditionalProps>) => KeyboardEventHandler<HTMLLIElement>;
76
+ resetTabbableIndex: () => void;
76
77
  };
77
78
  export {};
@@ -2,6 +2,7 @@
2
2
  /* eslint-disable @typescript-eslint/no-unnecessary-condition */
3
3
  import { useState, useCallback, useEffect } from 'react';
4
4
  import { findSiblingElements } from '../../utilities/dom/findSiblingElements';
5
+ import { getDeepActiveElement } from '../../utilities/dom/getDeepActiveElement';
5
6
  import { escapeAttributeSelector } from '../utils/getFlatOptions';
6
7
  import { isOptionDisabled } from '../utils/isSelectableOption';
7
8
  const DEFAULT_SCROLL_OPTIONS = {
@@ -29,28 +30,41 @@ export function useKeyboardNavigation({ isOptionPanelOpen, loading, enableSearch
29
30
  : -1;
30
31
  return selectedIndex !== -1 ? selectedIndex : 0;
31
32
  }, [flatOptions, localSelectedOption]);
32
- const [tabbableOptionIndex, setTabbableOptionIndex] = useState(getDefaultTabbableIndex());
33
- // Adjusted during render (React's documented pattern for resyncing state to changed
34
- // props) rather than in an effect, so the UI never flashes the stale index for one frame.
35
- const [prevFlatOptions, setPrevFlatOptions] = useState(flatOptions);
36
- const [prevLocalSelectedOption, setPrevLocalSelectedOption] = useState(localSelectedOption);
37
- if (flatOptions !== prevFlatOptions || localSelectedOption !== prevLocalSelectedOption) {
38
- setPrevFlatOptions(flatOptions);
39
- setPrevLocalSelectedOption(localSelectedOption);
40
- setTabbableOptionIndex(getDefaultTabbableIndex());
41
- }
33
+ // The focused option is stored by its value, and the tabbable index is calculated during render.
34
+ // Storing the index and resetting it whenever `flatOptions` is a different array would set state
35
+ // during render, which loops forever when `flatOptions` is a new array on every render (e.g.
36
+ // `options` is omitted). The focused option stays tabbable while it is in `flatOptions`. The
37
+ // caller forgets it with `resetTabbableIndex` whenever the option panel opens, so every opening
38
+ // starts from the selected option, or the first option when nothing is selected.
39
+ const [focusedOption, setFocusedOption] = useState(null);
40
+ const focusedOptionIndex = focusedOption
41
+ ? flatOptions.findIndex((option) => option.value === focusedOption.value)
42
+ : -1;
43
+ const tabbableOptionIndex = focusedOptionIndex !== -1 ? focusedOptionIndex : getDefaultTabbableIndex();
42
44
  const resetTabbableIndex = useCallback(() => {
43
- setTabbableOptionIndex(getDefaultTabbableIndex());
44
- }, [getDefaultTabbableIndex]);
45
+ setFocusedOption(null);
46
+ }, []);
45
47
  const handleOptionFocus = useCallback((optionIndex) => {
46
- setTabbableOptionIndex(optionIndex);
48
+ const option = flatOptions[optionIndex];
49
+ if (!option) {
50
+ setFocusedOption(null);
51
+ return;
52
+ }
53
+ // Keep the current object when the same option is focused again, so React can skip the re-render.
54
+ setFocusedOption((previous) => previous && previous.value === option.value ? previous : { value: option.value });
47
55
  // Don't do auto-scroll on focus change - let selection handle its own scrolling
48
56
  // This prevents interference between focus events (from clicks) and selection logic
49
57
  // Auto-scroll should only happen when an option is actually selected, not just focused
50
- }, []);
58
+ }, [flatOptions]);
51
59
  useEffect(() => {
52
60
  if (!isOptionPanelOpen)
53
61
  return;
62
+ // The effect also runs when the options change while the panel is open (e.g. infinite scroll
63
+ // loaded more). Don't take the focus away from an option the user has already moved to.
64
+ const activeElement = getDeepActiveElement();
65
+ if (activeElement?.getAttribute('role') === 'option' && optionPanelRef.current?.contains(activeElement)) {
66
+ return;
67
+ }
54
68
  // When loading, focus on the option panel root div to enable ESC key functionality
55
69
  if (loading) {
56
70
  optionPanelRef.current?.focus({ preventScroll: true });
@@ -220,5 +234,6 @@ export function useKeyboardNavigation({ isOptionPanelOpen, loading, enableSearch
220
234
  tabbableOptionIndex,
221
235
  handleOptionFocus,
222
236
  createKeyDownHandler,
237
+ resetTabbableIndex,
223
238
  };
224
239
  }
@@ -9,6 +9,12 @@ export declare const DEFAULT_MAX_WIDTH = "268px";
9
9
  * - `className`: Applied to the root wrapper element that contains the trigger children
10
10
  * - `contentProps.className`: Applied to the floating tooltip content element
11
11
  *
12
+ * ## Layering
13
+ *
14
+ * The tooltip content is drawn on the `--mfui-z-index-tooltip` layer (`10000`), above every other layer MFUI renders.
15
+ * To place it above an application overlay with a higher `z-index`, set `z-index` through `contentProps.className`.
16
+ * See the "Layering" page in Storybook for the full layer order.
17
+ *
12
18
  * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/span
13
19
  *
14
20
  * @example
@@ -22,6 +22,12 @@ export const DEFAULT_MAX_WIDTH = '268px';
22
22
  * - `className`: Applied to the root wrapper element that contains the trigger children
23
23
  * - `contentProps.className`: Applied to the floating tooltip content element
24
24
  *
25
+ * ## Layering
26
+ *
27
+ * The tooltip content is drawn on the `--mfui-z-index-tooltip` layer (`10000`), above every other layer MFUI renders.
28
+ * To place it above an application overlay with a higher `z-index`, set `z-index` through `contentProps.className`.
29
+ * See the "Layering" page in Storybook for the full layer order.
30
+ *
25
31
  * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/span
26
32
  *
27
33
  * @example
@@ -10,7 +10,7 @@ type DataTableBodySlotRecipeVariantMap = {
10
10
  [key in keyof DataTableBodySlotRecipeVariant]: Array<DataTableBodySlotRecipeVariant[key]>
11
11
  }
12
12
 
13
- type DataTableBodySlotRecipeSlot = "root"
13
+ type DataTableBodySlotRecipeSlot = "root" | "spacer"
14
14
 
15
15
  export type DataTableBodySlotRecipeVariantProps = {
16
16
  [key in keyof DataTableBodySlotRecipeVariant]?: ConditionalValue<DataTableBodySlotRecipeVariant[key]> | undefined
@@ -6,6 +6,10 @@ const dataTableBodySlotRecipeSlotNames = [
6
6
  [
7
7
  "root",
8
8
  "DataTableBody__root"
9
+ ],
10
+ [
11
+ "spacer",
12
+ "DataTableBody__spacer"
9
13
  ]
10
14
  ];
11
15
  const dataTableBodySlotRecipeSlotFns = /* @__PURE__ */ dataTableBodySlotRecipeSlotNames.map(([slotName, slotKey]) => [slotName, createRecipe(slotKey, dataTableBodySlotRecipeDefaultVariants, getSlotCompoundVariant(dataTableBodySlotRecipeCompoundVariants, slotName))]);
@@ -5667,6 +5667,10 @@ const tokens = {
5667
5667
  "value": 1000,
5668
5668
  "variable": "var(--mfui-z-index-toast-item)"
5669
5669
  },
5670
+ "zIndex.tooltip": {
5671
+ "value": 10000,
5672
+ "variable": "var(--mfui-z-index-tooltip)"
5673
+ },
5670
5674
  "breakpoints.sm": {
5671
5675
  "value": "600px",
5672
5676
  "variable": "var(--mfui-breakpoints-sm)"
@@ -15,7 +15,7 @@ export type DurationToken = "mfui.core.transition.duration.none" | "mfui.core.tr
15
15
 
16
16
  export type EasingToken = "mfui.core.transition.easing.linear" | "mfui.core.transition.easing.ease-in-out" | "mfui.transition.easing.side-pane.in" | "mfui.transition.easing.side-pane.out" | "mfui.transition.easing.split-view.in" | "mfui.transition.easing.split-view.out"
17
17
 
18
- export type ZIndexToken = "dialog" | "toast.container" | "toast.item"
18
+ export type ZIndexToken = "dialog" | "toast.container" | "toast.item" | "tooltip"
19
19
 
20
20
  export type BreakpointToken = "sm" | "lg"
21
21
 
package/dist/styles.css CHANGED
@@ -153,6 +153,7 @@
153
153
  --mfui-z-index-dialog: 5000;
154
154
  --mfui-z-index-toast-container: 9999;
155
155
  --mfui-z-index-toast-item: 1000;
156
+ --mfui-z-index-tooltip: 10000;
156
157
  --mfui-colors-mfui\.color\.base\.content\.none: #262626ff;
157
158
  --mfui-colors-mfui\.color\.base\.content\.hovered: #111111ff;
158
159
  --mfui-colors-mfui\.color\.base\.content\.pressed: #000000ff;
@@ -1444,6 +1445,7 @@
1444
1445
  padding-inline: var(--mfui-spacing-mfui\.size\.padding\.control\.horizontal\.comfort);
1445
1446
  border-radius: var(--mfui-radii-mfui\.size\.radius\.indicator-component\.comfort);
1446
1447
  position: fixed;
1448
+ z-index: var(--mfui-z-index-tooltip);
1447
1449
  background-color: var(--mfui-colors-mfui\.color\.neutral\.background\.none);
1448
1450
  color: var(--mfui-colors-mfui\.color\.base\.inverted-content\.none);
1449
1451
  box-shadow: var(--mfui-shadows-mfui\.elevation\.plus-1\.shadow-1);
@@ -2977,6 +2979,11 @@
2977
2979
  top: 50%;
2978
2980
  }
2979
2981
 
2982
+ .mfui-kWseNH {
2983
+ padding: 0;
2984
+ border: 0;
2985
+ }
2986
+
2980
2987
  .mfui-dDOVnx {
2981
2988
  vertical-align: top;
2982
2989
  }
@@ -7793,9 +7800,7 @@ li:last-child > .mfui-cXGJls {
7793
7800
  }
7794
7801
 
7795
7802
  .mfui-coGcRr {
7796
- width: 32px;
7797
- min-width: 32px;
7798
- max-width: 32px;
7803
+ width: calc(var(--mfui-sizes-mfui\.size\.dimension\.icon\.square\.comfort) + var(--mfui-spacing-mfui\.size\.padding\.data-table-cell\.horizontal\.comfort) * 2 + var(--mfui-spacing-mfui\.size\.padding\.container\.horizontal\.comfort));
7799
7804
  }
7800
7805
 
7801
7806
  .mfui-jqZsnQ:not(:disabled, [disabled], [data-disabled]):not(:has(input:disabled)) {
@@ -8001,7 +8006,7 @@ li:last-child > .mfui-cXGJls {
8001
8006
  }
8002
8007
 
8003
8008
  .mfui-hdkeDU {
8004
- width: 32px;
8009
+ width: calc(var(--mfui-sizes-mfui\.size\.dimension\.icon\.square\.comfort) + var(--mfui-spacing-mfui\.size\.padding\.data-table-cell\.horizontal\.comfort) * 2 + var(--mfui-spacing-mfui\.size\.padding\.container\.horizontal\.comfort));
8005
8010
  }
8006
8011
 
8007
8012
  .mfui-dQChFX:not(:disabled, [disabled], [data-disabled]) {