@moneyforward/mfui-components 4.6.0 → 4.8.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 (53) hide show
  1. package/dist/src/DataTable/DataTable.d.ts +1 -1
  2. package/dist/src/DataTable/DataTable.js +59 -6
  3. package/dist/src/DataTable/DataTable.types.d.ts +101 -0
  4. package/dist/src/DataTable/DataTableBody/DataTableBody.d.ts +2 -1
  5. package/dist/src/DataTable/DataTableBody/DataTableBody.js +72 -8
  6. package/dist/src/DataTable/DataTableBody/DataTableBody.types.d.ts +81 -1
  7. package/dist/src/DataTable/DataTableBody/hooks/useVirtualizedRows.d.ts +64 -0
  8. package/dist/src/DataTable/DataTableBody/hooks/useVirtualizedRows.js +53 -0
  9. package/dist/src/DataTable/DataTableBody/index.d.ts +1 -1
  10. package/dist/src/DataTable/DataTableHeader/DataTableHeader.js +13 -2
  11. package/dist/src/DataTable/DataTableHeaderCell/DataTableHeaderCell.js +5 -2
  12. package/dist/src/DataTable/DataTableHeaderRow/DataTableHeaderRow.js +35 -16
  13. package/dist/src/DataTable/DataTableHeaderRow/DataTableHeaderRow.types.d.ts +9 -0
  14. package/dist/src/DataTable/DataTableProvider.d.ts +45 -3
  15. package/dist/src/DataTable/DataTableProvider.js +30 -3
  16. package/dist/src/DataTable/DataTableRow/DataTableRow.js +21 -12
  17. package/dist/src/DataTable/hooks/useVirtualizedColumns.d.ts +73 -0
  18. package/dist/src/DataTable/hooks/useVirtualizedColumns.js +109 -0
  19. package/dist/src/DataTable/index.d.ts +2 -2
  20. package/dist/src/DataTable/shared/commonStyles.d.ts +11 -0
  21. package/dist/src/DataTable/shared/commonStyles.js +11 -0
  22. package/dist/src/DataTable/utils/renderVirtualizedColumns.d.ts +42 -0
  23. package/dist/src/DataTable/utils/renderVirtualizedColumns.js +40 -0
  24. package/dist/src/DataTable/utils/resolveColumnWidths.d.ts +28 -0
  25. package/dist/src/DataTable/utils/resolveColumnWidths.js +41 -0
  26. package/dist/src/MultipleSelectBox/MultipleSelectBox.d.ts +1 -1
  27. package/dist/src/MultipleSelectBox/MultipleSelectBox.js +20 -8
  28. package/dist/src/MultipleSelectBox/MultipleSelectBox.types.d.ts +33 -0
  29. package/dist/src/MultipleSelectBox/hooks/useOptionKeyboardNavigation.d.ts +1 -0
  30. package/dist/src/MultipleSelectBox/hooks/useOptionKeyboardNavigation.js +23 -16
  31. package/dist/src/SelectBox/SelectBox.js +18 -7
  32. package/dist/src/SelectBox/SelectBox.types.d.ts +33 -0
  33. package/dist/src/SelectBox/SelectBoxOption.d.ts +5 -1
  34. package/dist/src/SelectBox/SelectBoxOption.js +3 -4
  35. package/dist/src/SelectBox/SelectBoxOptionGroup.d.ts +5 -1
  36. package/dist/src/SelectBox/SelectBoxOptionGroup.js +2 -2
  37. package/dist/src/SelectBox/hooks/useKeyboardNavigation.d.ts +1 -0
  38. package/dist/src/SelectBox/hooks/useKeyboardNavigation.js +29 -14
  39. package/dist/src/Tooltip/Tooltip.d.ts +6 -0
  40. package/dist/src/Tooltip/Tooltip.js +6 -0
  41. package/dist/src/shared/OptionLabel/OptionLabel.d.ts +45 -0
  42. package/dist/src/shared/OptionLabel/OptionLabel.js +9 -0
  43. package/dist/src/shared/OptionLabel/index.d.ts +1 -0
  44. package/dist/src/shared/OptionLabel/index.js +1 -0
  45. package/dist/styled-system/recipes/data-table-body-slot-recipe.d.ts +1 -1
  46. package/dist/styled-system/recipes/data-table-body-slot-recipe.js +4 -0
  47. package/dist/styled-system/recipes/data-table-slot-recipe.d.ts +5 -1
  48. package/dist/styled-system/recipes/data-table-slot-recipe.js +19 -1
  49. package/dist/styled-system/tokens/index.js +4 -0
  50. package/dist/styled-system/tokens/tokens.d.ts +1 -1
  51. package/dist/styles.css +40 -4
  52. package/dist/tsconfig.build.tsbuildinfo +1 -1
  53. package/package.json +7 -7
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The width of a column in pixels when neither `getColumnWidth` nor its header cell declares one.
3
+ */
4
+ export const DEFAULT_COLUMN_WIDTH = 160;
5
+ /**
6
+ * Converts the `style.width` of a header cell into pixels.
7
+ * Only a number or a `px` string is understood, because the column virtualizer needs the width in
8
+ * pixels before anything is laid out. Any other value counts as not declared.
9
+ *
10
+ * @param width - The declared width.
11
+ *
12
+ * @returns The width in pixels, or `undefined` when it cannot be resolved without layout.
13
+ */
14
+ function toPixels(width) {
15
+ if (typeof width === 'number')
16
+ return Number.isFinite(width) && width > 0 ? width : undefined;
17
+ if (typeof width !== 'string')
18
+ return undefined;
19
+ const match = /^\s*(\d+(?:\.\d+)?)px\s*$/u.exec(width);
20
+ return match?.[1] === undefined ? undefined : Number(match[1]);
21
+ }
22
+ /**
23
+ * Resolves the width of every consumer column, in pixels, for column virtualization.
24
+ *
25
+ * Each column takes the first of:
26
+ *
27
+ * 1. `getColumnWidth(index)`, when it returns a width.
28
+ * 2. The `style.width` of the column's `DataTable.HeaderCell`, when it is a number or a `px` string.
29
+ * 3. {@link DEFAULT_COLUMN_WIDTH}.
30
+ *
31
+ * @param declaredColumnWidths - The `style.width` of each header cell, which also gives the number
32
+ * of consumer columns.
33
+ *
34
+ * @param getColumnWidth - The consumer's width callback, which receives the column index counted as
35
+ * `leftFixedColumnIndex` is, without the selection column.
36
+ *
37
+ * @returns The width of each consumer column in pixels.
38
+ */
39
+ export function resolveColumnWidths(declaredColumnWidths, getColumnWidth) {
40
+ return declaredColumnWidths.map((declaredWidth, index) => getColumnWidth?.(index) ?? toPixels(declaredWidth) ?? DEFAULT_COLUMN_WIDTH);
41
+ }
@@ -4,4 +4,4 @@ import { type MultipleSelectBoxProps, type AllowedValueTypes } from './MultipleS
4
4
  * This component is separated from the SelectBox component because it has a different behavior.
5
5
  * This component switches the variants of looks and behaviors depends on the props: size, invalid, disabled.
6
6
  */
7
- export declare function MultipleSelectBox<T extends AllowedValueTypes = string, AdditionalProps extends Record<string, unknown> = Record<string, never>>({ id, triggerProps, triggerWrapperProps, size, options, defaultValue, placeholder, emptyMessage, disabled, invalid, required, caution, targetDOMNode, name, onChange, value, showDisplayValueAsTag, renderDisplayValue, enableSearchOptions, notFoundMessage, searchBoxProps, loading, onSearchOptions, clearButtonProps, disableClearButton, optionPanelProps, popoverWrapperProps, enableAllOptionsControls, enableApplyControls, showSelectedCount, selectAllButtonProps, clearAllButtonProps, selectedCountProps, cancelButtonProps, applyButtonProps, renderOption, renderPopoverHeader, renderOptionList, renderPopoverFooter, onOpenStateChanged, enableAutoUnmount, onBlur, showGroupOptionDivider, enableVirtualization, virtualizationOptions, infiniteScroll, enableCreateOption, onCreateOption, createOptionProps, }: MultipleSelectBoxProps<T, AdditionalProps>): import("react/jsx-runtime").JSX.Element;
7
+ export declare function MultipleSelectBox<T extends AllowedValueTypes = string, AdditionalProps extends Record<string, unknown> = Record<string, never>>({ id, triggerProps, triggerWrapperProps, size, options, defaultValue, placeholder, emptyMessage, disabled, invalid, required, caution, targetDOMNode, name, onChange, value, showDisplayValueAsTag, renderDisplayValue, enableSearchOptions, notFoundMessage, searchBoxProps, loading, onSearchOptions, clearButtonProps, disableClearButton, optionPanelProps, popoverWrapperProps, enableAllOptionsControls, enableApplyControls, showSelectedCount, selectAllButtonProps, clearAllButtonProps, selectedCountProps, cancelButtonProps, applyButtonProps, renderOption, renderOptionLabelSlot, renderPopoverHeader, renderOptionList, renderPopoverFooter, onOpenStateChanged, enableAutoUnmount, onBlur, showGroupOptionDivider, enableVirtualization, virtualizationOptions, infiniteScroll, enableCreateOption, onCreateOption, createOptionProps, }: MultipleSelectBoxProps<T, AdditionalProps>): import("react/jsx-runtime").JSX.Element;
@@ -26,19 +26,23 @@ import { cx } from '../../styled-system/css';
26
26
  import { flattenOptions } from './utils/flattenOptions';
27
27
  import { getGroupLabelId } from '../utilities/dom/getGroupLabelId';
28
28
  import { OptionGroupLabelDescriptions } from '../shared/OptionGroupLabelDescriptions';
29
+ import { OptionLabel } from '../shared/OptionLabel';
29
30
  import { isSelectableOption, isOptionDisabled, isOptionSelected } from './utils/isSelectableOption';
30
31
  import { useVirtualizedMultipleSelectBoxOptions } from './hooks/useVirtualizedMultipleSelectBoxOptions';
31
32
  import { useInfiniteScroll } from '../utilities/dom/useInfiniteScroll';
32
33
  // How many skeleton items to render while the component is loading.
33
34
  const SKELETON_ITEM_COUNT = 4;
35
+ // The default for an omitted `options` prop. A default written inline (`options = []`) is a new
36
+ // array on every render, which invalidates every memoized value derived from the options.
37
+ const EMPTY_OPTIONS = [];
34
38
  /**
35
39
  * The MultipleSelectBox component.
36
40
  * This component is separated from the SelectBox component because it has a different behavior.
37
41
  * This component switches the variants of looks and behaviors depends on the props: size, invalid, disabled.
38
42
  */
39
- export function MultipleSelectBox({ id, triggerProps, triggerWrapperProps, size, options = [], defaultValue,
43
+ export function MultipleSelectBox({ id, triggerProps, triggerWrapperProps, size, options = EMPTY_OPTIONS, defaultValue,
40
44
  // eslint-disable-next-line @typescript-eslint/no-deprecated
41
- placeholder, emptyMessage, disabled, invalid, required, caution, targetDOMNode, name, onChange, value, showDisplayValueAsTag, renderDisplayValue, enableSearchOptions = false, notFoundMessage, searchBoxProps, loading = false, onSearchOptions, clearButtonProps, disableClearButton = false, optionPanelProps, popoverWrapperProps, enableAllOptionsControls = false, enableApplyControls = false, showSelectedCount = false, selectAllButtonProps, clearAllButtonProps, selectedCountProps, cancelButtonProps, applyButtonProps, renderOption, renderPopoverHeader, renderOptionList, renderPopoverFooter, onOpenStateChanged, enableAutoUnmount = true, onBlur, showGroupOptionDivider, enableVirtualization = false, virtualizationOptions, infiniteScroll, enableCreateOption = false, onCreateOption, createOptionProps, }) {
45
+ placeholder, emptyMessage, disabled, invalid, required, caution, targetDOMNode, name, onChange, value, showDisplayValueAsTag, renderDisplayValue, enableSearchOptions = false, notFoundMessage, searchBoxProps, loading = false, onSearchOptions, clearButtonProps, disableClearButton = false, optionPanelProps, popoverWrapperProps, enableAllOptionsControls = false, enableApplyControls = false, showSelectedCount = false, selectAllButtonProps, clearAllButtonProps, selectedCountProps, cancelButtonProps, applyButtonProps, renderOption, renderOptionLabelSlot, renderPopoverHeader, renderOptionList, renderPopoverFooter, onOpenStateChanged, enableAutoUnmount = true, onBlur, showGroupOptionDivider, enableVirtualization = false, virtualizationOptions, infiniteScroll, enableCreateOption = false, onCreateOption, createOptionProps, }) {
42
46
  const classes = multipleSelectBoxSlotRecipe({ showGroupOptionDivider });
43
47
  const triggerRef = useRef(null);
44
48
  const listBoxId = useId();
@@ -187,7 +191,7 @@ placeholder, emptyMessage, disabled, invalid, required, caution, targetDOMNode,
187
191
  // Create an array of focusable (selectable and not disabled) options from filteredOptions
188
192
  const focusableOptions = useMemo(() => filteredOptions.filter((option) => isSelectableOption(option) && option.value != null && !isOptionDisabled(option)), [filteredOptions]);
189
193
  // Manage the keyboard navigation of options
190
- const { handleOptionFocus, createKeyDownHandler, tabbableOptionIndex } = useOptionKeyboardNavigation({
194
+ const { handleOptionFocus, createKeyDownHandler, tabbableOptionIndex, resetTabbableIndex } = useOptionKeyboardNavigation({
191
195
  filteredOptions: focusableOptions,
192
196
  selectedValues: enableApplyControls ? temporaryValues : localSelectedValuesSet,
193
197
  onSelectOption: handleSelectOption,
@@ -211,7 +215,7 @@ placeholder, emptyMessage, disabled, invalid, required, caution, targetDOMNode,
211
215
  }, children: _jsx("li", { role: "presentation", className: cx(classes.listItem, 'mfui-MultipleSelectBox__listItem'), "data-mfui-group-label": "true", children: _jsx(Typography, { variant: "label", children: option.label }) }) }, virtualItem.key));
212
216
  }
213
217
  if (isSelectableOption(option) && option.value != null) {
214
- const { value, label } = option;
218
+ const { value } = option;
215
219
  const isDisabled = isOptionDisabled(option);
216
220
  const selectedSet = enableApplyControls ? temporaryValues : localSelectedValuesSet;
217
221
  const isSelected = isOptionSelected(option, selectedSet);
@@ -238,7 +242,7 @@ placeholder, emptyMessage, disabled, invalid, required, caution, targetDOMNode,
238
242
  return;
239
243
  }
240
244
  handleSelectOption(option);
241
- }, children: renderOption ? (renderOption(option, isSelected)) : (_jsxs(_Fragment, { children: [isSelected ? _jsx(CheckboxChecked, { "aria-hidden": "true" }) : _jsx(CheckboxUnchecked, { "aria-hidden": "true" }), _jsx(Typography, { variant: "body", children: label })] })) }) }) }, virtualItem.key));
245
+ }, children: renderOption ? (renderOption(option, isSelected)) : (_jsxs(_Fragment, { children: [isSelected ? _jsx(CheckboxChecked, { "aria-hidden": "true" }) : _jsx(CheckboxUnchecked, { "aria-hidden": "true" }), _jsx(OptionLabel, { option: option, isSelected: isSelected, renderOptionLabelSlot: renderOptionLabelSlot, slotContentName: "multiple-select-box-option-label-slot" })] })) }) }) }, virtualItem.key));
242
246
  }
243
247
  return null;
244
248
  });
@@ -251,6 +255,7 @@ placeholder, emptyMessage, disabled, invalid, required, caution, targetDOMNode,
251
255
  classes,
252
256
  handleSelectOption,
253
257
  renderOption,
258
+ renderOptionLabelSlot,
254
259
  createKeyDownHandler,
255
260
  handleOptionFocus,
256
261
  tabbableOptionIndex,
@@ -271,7 +276,7 @@ placeholder, emptyMessage, disabled, invalid, required, caution, targetDOMNode,
271
276
  return null;
272
277
  }
273
278
  // Only reaches here if isSelectableOption(option) is true and value is not null/undefined
274
- const { value, label } = option;
279
+ const { value } = option;
275
280
  const isDisabled = isOptionDisabled(option);
276
281
  const selectedSet = enableApplyControls ? temporaryValues : localSelectedValuesSet;
277
282
  const isSelected = isOptionSelected(option, selectedSet);
@@ -289,7 +294,7 @@ placeholder, emptyMessage, disabled, invalid, required, caution, targetDOMNode,
289
294
  return;
290
295
  }
291
296
  handleSelectOption(option);
292
- }, children: renderOption ? (renderOption(option, isSelected)) : (_jsxs(_Fragment, { children: [isSelected ? _jsx(CheckboxChecked, { "aria-hidden": "true" }) : _jsx(CheckboxUnchecked, { "aria-hidden": "true" }), _jsx(Typography, { variant: "body", children: label })] })) }) }, String(value)));
297
+ }, children: renderOption ? (renderOption(option, isSelected)) : (_jsxs(_Fragment, { children: [isSelected ? _jsx(CheckboxChecked, { "aria-hidden": "true" }) : _jsx(CheckboxUnchecked, { "aria-hidden": "true" }), _jsx(OptionLabel, { option: option, isSelected: isSelected, renderOptionLabelSlot: renderOptionLabelSlot, slotContentName: "multiple-select-box-option-label-slot" })] })) }) }, String(value)));
293
298
  }
294
299
  // Only render group label if truly a group label (no value property at all)
295
300
  const { label } = option;
@@ -304,6 +309,7 @@ placeholder, emptyMessage, disabled, invalid, required, caution, targetDOMNode,
304
309
  classes,
305
310
  handleSelectOption,
306
311
  renderOption,
312
+ renderOptionLabelSlot,
307
313
  createKeyDownHandler,
308
314
  handleOptionFocus,
309
315
  tabbableOptionIndex,
@@ -373,7 +379,13 @@ placeholder, emptyMessage, disabled, invalid, required, caution, targetDOMNode,
373
379
  : undefined, children: renderListContent() }), isVirtualized ? renderInfiniteScrollLoading() : null, isVirtualized ? renderInfiniteScrollError('div') : null, _jsx(OptionGroupLabelDescriptions, { groups: rendersHiddenGroupLabels ? optionGroupLabels : [] })] }));
374
380
  const createOptionAreaNode = isCreateOptionVisible ? (_jsxs("div", { className: cx(classes.createOptionArea, 'mfui-MultipleSelectBox__createOptionArea'), children: [_jsxs("button", { "data-mfui-content": "create-new-option", type: "button", className: cx(classes.createOptionButton, 'mfui-MultipleSelectBox__createOptionButton'), disabled: isCreating, onClick: handleCreateOption, children: [_jsx(Add, { "aria-hidden": true }), createOptionProps?.renderLabel?.(searchText.trim()) ?? (_jsxs(_Fragment, { children: [_jsx(Typography, { variant: "strongBody", children: searchText.trim() }), _jsx(Typography, { variant: "body", children: "\u3092\u8FFD\u52A0" })] }))] }), createError ? (_jsx("div", { className: cx(classes.createOptionErrorMessage, 'mfui-MultipleSelectBox__createOptionErrorMessage'), children: _jsx(HelpMessage, { messageType: "error", children: createOptionProps?.renderErrorMessage?.(searchText.trim()) ??
375
381
  `${searchText.trim()}を追加できませんでした。` }) })) : null] })) : null;
382
+ const handleOpenStateChanged = (open) => {
383
+ // Every opening starts from the first selected option, not from the option focused last time.
384
+ if (open)
385
+ resetTabbableIndex();
386
+ toggleOptionPanel();
387
+ };
376
388
  return (_jsx(Popover, { renderTrigger: ({ setTriggerRef, togglePopover, handleTriggerKeyDown, handleTriggerBlur }) => (_jsx(MultipleSelectBoxTrigger, { ref: triggerRef, wrapperRef: setTriggerRef, selectedOptions: localSelectedOptions, id: id, disabled: disabled, triggerProps: triggerProps, triggerWrapperProps: triggerWrapperProps, name: name,
377
389
  // eslint-disable-next-line @typescript-eslint/no-deprecated
378
- placeholder: placeholder, size: size, listBoxId: listBoxId, isOptionPanelOpen: isOptionPanelOpen, invalid: invalid, required: required, caution: caution, showDisplayValueAsTag: showDisplayValueAsTag, renderDisplayValue: renderDisplayValue, clearButtonProps: clearButtonProps, disableClearButton: disableClearButton, updateSelectedValues: updateSelectedValues, onClick: togglePopover, onKeyDown: handleTriggerKeyDown, onBlur: handleTriggerBlur })), contentProps: { className: classes.popover }, minWidth: optionPanelProps?.minWidth, maxHeight: popoverWrapperProps?.maxHeight, allowedPlacements: optionPanelProps?.allowedPlacements, renderContent: () => (_jsxs("div", { ref: optionPanelRef, className: cx(classes.optionPanel, 'mfui-MultipleSelectBox__optionPanel', optionPanelProps?.className), tabIndex: -1, onKeyDown: handleKeyDownMenu, children: [enableSearchOptions || enableAllOptionsControls ? (_jsx(VStack, { className: cx(classes.menuHeader, 'mfui-MultipleSelectBox__menuHeader'), gap: "8px", children: renderPopoverHeader ? (renderPopoverHeader({ searchNode, allOptionsControlsNode })) : (_jsxs(_Fragment, { children: [searchNode, allOptionsControlsNode] })) })) : null, renderOptionList ? (renderOptionList({ optionsNode, createOptionAreaNode })) : (_jsxs(_Fragment, { children: [optionsNode, createOptionAreaNode] })), enableApplyControls || showSelectedCount ? (_jsx(HStack, { className: cx(classes.menuFooter, 'mfui-MultipleSelectBox__menuFooter'), justifyContent: "space-between", alignItems: "center", children: renderPopoverFooter ? (renderPopoverFooter({ selectedCountNode, applyControlsNode })) : (_jsxs(_Fragment, { children: [selectedCountNode, applyControlsNode] })) })) : null] })), open: isOptionPanelOpen, targetDOMNode: targetDOMNode, enableAutoUnmount: enableAutoUnmount, onOpenStateChanged: toggleOptionPanel, onBlur: onBlur }));
390
+ placeholder: placeholder, size: size, listBoxId: listBoxId, isOptionPanelOpen: isOptionPanelOpen, invalid: invalid, required: required, caution: caution, showDisplayValueAsTag: showDisplayValueAsTag, renderDisplayValue: renderDisplayValue, clearButtonProps: clearButtonProps, disableClearButton: disableClearButton, updateSelectedValues: updateSelectedValues, onClick: togglePopover, onKeyDown: handleTriggerKeyDown, onBlur: handleTriggerBlur })), contentProps: { className: classes.popover }, minWidth: optionPanelProps?.minWidth, maxHeight: popoverWrapperProps?.maxHeight, allowedPlacements: optionPanelProps?.allowedPlacements, renderContent: () => (_jsxs("div", { ref: optionPanelRef, className: cx(classes.optionPanel, 'mfui-MultipleSelectBox__optionPanel', optionPanelProps?.className), tabIndex: -1, onKeyDown: handleKeyDownMenu, children: [enableSearchOptions || enableAllOptionsControls ? (_jsx(VStack, { className: cx(classes.menuHeader, 'mfui-MultipleSelectBox__menuHeader'), gap: "8px", children: renderPopoverHeader ? (renderPopoverHeader({ searchNode, allOptionsControlsNode })) : (_jsxs(_Fragment, { children: [searchNode, allOptionsControlsNode] })) })) : null, renderOptionList ? (renderOptionList({ optionsNode, createOptionAreaNode })) : (_jsxs(_Fragment, { children: [optionsNode, createOptionAreaNode] })), enableApplyControls || showSelectedCount ? (_jsx(HStack, { className: cx(classes.menuFooter, 'mfui-MultipleSelectBox__menuFooter'), justifyContent: "space-between", alignItems: "center", children: renderPopoverFooter ? (renderPopoverFooter({ selectedCountNode, applyControlsNode })) : (_jsxs(_Fragment, { children: [selectedCountNode, applyControlsNode] })) })) : null] })), open: isOptionPanelOpen, targetDOMNode: targetDOMNode, enableAutoUnmount: enableAutoUnmount, onOpenStateChanged: handleOpenStateChanged, onBlur: onBlur }));
379
391
  }
@@ -4,6 +4,7 @@ import { type SearchBoxProps } from '../SearchBox';
4
4
  import { type MultipleSelectBoxTriggerProps, type PassedProps } from './MultipleSelectBoxTrigger/MultipleSelectBoxTrigger.types';
5
5
  import { type PopoverProps } from '../Popover';
6
6
  import { type InfiniteScrollDirection } from '../utilities/dom/useInfiniteScroll';
7
+ import { type RenderOptionLabelSlot } from '../shared/OptionLabel';
7
8
  export type AllowedValueTypes = string | number | undefined;
8
9
  export type VirtualOptionTypes = Pick<VirtualizerOptions<HTMLElement, Element>, 'estimateSize' | 'overscan'>;
9
10
  export type InfiniteScrollConfig = {
@@ -313,6 +314,38 @@ export type MultipleSelectBoxProps<T extends AllowedValueTypes = string, Additio
313
314
  * ```
314
315
  */
315
316
  renderOption?: (option: MultipleSelectBoxOption<T, AdditionalProps>, isSelected: boolean) => React.ReactNode;
317
+ /**
318
+ * The function to customize the label area of each option in the list, such as adding an icon or tags.
319
+ *
320
+ * Unlike `renderOption`, only the label text is replaced. The checkbox and the
321
+ * hover, focus, pressed, and disabled styles of the option are kept.
322
+ *
323
+ * - The `label` of the option is still required and is used for search and the trigger.
324
+ * - The slot content becomes the accessible name of the option. Include the same words as `label`,
325
+ * as text or as the accessible name of an image, so that what users read matches what they can type.
326
+ * - Return only non-interactive content, because the option itself is the interactive element.
327
+ * - Ignored when `renderOption` is provided.
328
+ * - Define the function outside the component or wrap it in `useCallback`, so that the options are
329
+ * not re-rendered every time the parent renders.
330
+ *
331
+ * @param option - The option object being rendered (includes additional properties if defined)
332
+ *
333
+ * @param state.isSelected - Whether the option is currently selected
334
+ *
335
+ * @example
336
+ * ```tsx
337
+ * <MultipleSelectBox
338
+ * options={options}
339
+ * renderOptionLabelSlot={(option) => (
340
+ * <>
341
+ * <Icon aria-hidden />
342
+ * <span>{option.label}</span>
343
+ * </>
344
+ * )}
345
+ * />
346
+ * ```
347
+ */
348
+ renderOptionLabelSlot?: RenderOptionLabelSlot<MultipleSelectBoxOption<T, AdditionalProps>>;
316
349
  /**
317
350
  * Customize the popover header content.
318
351
  *
@@ -34,5 +34,6 @@ export declare function useOptionKeyboardNavigation<T extends AllowedValueTypes
34
34
  tabbableOptionIndex: number;
35
35
  handleOptionFocus: (optionIndex: number) => void;
36
36
  createKeyDownHandler: (option: MultipleSelectBoxOption<T, AdditionalProps>) => KeyboardEventHandler<HTMLLIElement>;
37
+ resetTabbableIndex: () => void;
37
38
  };
38
39
  export {};
@@ -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,14 +33,17 @@ 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
- 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;
46
+ placeholder, emptyMessage, disabled, invalid, caution = false, required, targetDOMNode, name, onChange, onBlur, value, enableSearchOptions = false, notFoundMessage, searchBoxProps, loading = false, onSearchOptions, renderDisplayValue, renderOption, renderOptionLabelSlot, renderPopoverContent, enableAutoScrollToSelectedOption, popoverContentProps, popoverWrapperProps, onOpenStateChanged, showGroupOptionDivider, triggerWrapperProps, enableAutoUnmount = true, enableConstrainedPopoverWidth = false, enableVirtualization = false, virtualizationOptions, infiniteScroll, } = props;
44
47
  const classes = selectBoxSlotRecipe({ size, showGroupOptionDivider });
45
48
  const triggerRef = useRef(null);
46
49
  const listBoxId = useId();
@@ -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,
@@ -269,7 +272,7 @@ export const SelectBox = forwardRef((props, ref) => {
269
272
  left: 0,
270
273
  width: '100%',
271
274
  transform: `translateY(${String(virtualItem.start)}px)`,
272
- }, children: _jsx(SelectBoxOptionComponent, { option: option, isSelected: localSelectedOption?.value === option.value, tabbableOptionIndex: tabbableOptionIndex, optionIndex: originalIndex ?? virtualItem.index, classes: classes, groupLabelId: groupLabelId, renderOption: renderOption, onFocus: handleOptionFocus, onClick: handleSelectOption, onKeyDown: (event) => {
275
+ }, children: _jsx(SelectBoxOptionComponent, { option: option, isSelected: localSelectedOption?.value === option.value, tabbableOptionIndex: tabbableOptionIndex, optionIndex: originalIndex ?? virtualItem.index, classes: classes, groupLabelId: groupLabelId, renderOption: renderOption, renderOptionLabelSlot: renderOptionLabelSlot, onFocus: handleOptionFocus, onClick: handleSelectOption, onKeyDown: (event) => {
273
276
  handleTypeAhead(event.nativeEvent);
274
277
  createKeyDownHandler(option)(event);
275
278
  } }) }, virtualItem.key));
@@ -280,6 +283,7 @@ export const SelectBox = forwardRef((props, ref) => {
280
283
  tabbableOptionIndex,
281
284
  classes,
282
285
  renderOption,
286
+ renderOptionLabelSlot,
283
287
  handleOptionFocus,
284
288
  handleSelectOption,
285
289
  handleTypeAhead,
@@ -294,14 +298,14 @@ export const SelectBox = forwardRef((props, ref) => {
294
298
  return null;
295
299
  if ('options' in option && Array.isArray(option.options)) {
296
300
  currentGroupLabelId = getGroupLabelId(listBoxId, optionIndex);
297
- return (_jsx(React.Fragment, { children: _jsx(SelectBoxOptionGroup, { groupOption: option, selectedOption: localSelectedOption, valueToIndexMap: valueToIndexMap, tabbableOptionIndex: tabbableOptionIndex, classes: classes, groupLabelId: currentGroupLabelId, hasHiddenGroupLabel: rendersHiddenGroupLabels, renderOption: renderOption, findFlatOptionIndexByValue: (_, option) => findFlatOptionIndexByValue(valueToIndexMap, option), onOptionFocus: handleOptionFocus, onOptionClick: handleSelectOption, onOptionKeyDown: (option) => (event) => {
301
+ return (_jsx(React.Fragment, { children: _jsx(SelectBoxOptionGroup, { groupOption: option, selectedOption: localSelectedOption, valueToIndexMap: valueToIndexMap, tabbableOptionIndex: tabbableOptionIndex, classes: classes, groupLabelId: currentGroupLabelId, hasHiddenGroupLabel: rendersHiddenGroupLabels, renderOption: renderOption, renderOptionLabelSlot: renderOptionLabelSlot, findFlatOptionIndexByValue: (_, option) => findFlatOptionIndexByValue(valueToIndexMap, option), onOptionFocus: handleOptionFocus, onOptionClick: handleSelectOption, onOptionKeyDown: (option) => (event) => {
298
302
  handleTypeAhead(event.nativeEvent);
299
303
  createKeyDownHandler(option)(event);
300
304
  } }) }, `group-label-${option.label}-${String(optionIndex)}`));
301
305
  }
302
306
  if (!('options' in option)) {
303
307
  const flatOptionIndex = findFlatOptionIndexByValue(valueToIndexMap, option);
304
- return (_jsx(SelectBoxOptionComponent, { option: option, isSelected: localSelectedOption?.value === option.value, tabbableOptionIndex: tabbableOptionIndex, optionIndex: flatOptionIndex, classes: classes, groupLabelId: currentGroupLabelId, renderOption: renderOption, onFocus: handleOptionFocus, onClick: handleSelectOption, onKeyDown: (event) => {
308
+ return (_jsx(SelectBoxOptionComponent, { option: option, isSelected: localSelectedOption?.value === option.value, tabbableOptionIndex: tabbableOptionIndex, optionIndex: flatOptionIndex, classes: classes, groupLabelId: currentGroupLabelId, renderOption: renderOption, renderOptionLabelSlot: renderOptionLabelSlot, onFocus: handleOptionFocus, onClick: handleSelectOption, onKeyDown: (event) => {
305
309
  handleTypeAhead(event.nativeEvent);
306
310
  createKeyDownHandler(option)(event);
307
311
  } }, option.value ?? `option-${option.label}-${String(optionIndex)}`));
@@ -317,6 +321,7 @@ export const SelectBox = forwardRef((props, ref) => {
317
321
  listBoxId,
318
322
  rendersHiddenGroupLabels,
319
323
  renderOption,
324
+ renderOptionLabelSlot,
320
325
  handleOptionFocus,
321
326
  handleSelectOption,
322
327
  handleTypeAhead,
@@ -384,6 +389,12 @@ export const SelectBox = forwardRef((props, ref) => {
384
389
  };
385
390
  const hasSelectedValue = value !== undefined || localSelectedOption !== null;
386
391
  const showClearButton = enableClearButton && hasSelectedValue;
392
+ const handleOpenStateChanged = (open) => {
393
+ // Every opening starts from the selected option, not from the option focused last time.
394
+ if (open)
395
+ resetTabbableIndex();
396
+ toggleOptionPanel();
397
+ };
387
398
  // Use Popover's centralized blur handling instead of custom logic
388
399
  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
400
  handleTypeAhead(event.nativeEvent);
@@ -391,6 +402,6 @@ export const SelectBox = forwardRef((props, ref) => {
391
402
  }, 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
403
  event.stopPropagation();
393
404
  handleClearValue();
394
- } }) })) : null] })), contentProps: { className: classes.popover }, enableAutoUnmount: enableAutoUnmount, onOpenStateChanged: toggleOptionPanel, onBlur: onBlur }));
405
+ } }) })) : null] })), contentProps: { className: classes.popover }, enableAutoUnmount: enableAutoUnmount, onOpenStateChanged: handleOpenStateChanged, onBlur: onBlur }));
395
406
  });
396
407
  SelectBox.displayName = 'SelectBox';
@@ -4,6 +4,7 @@ import { type SelectBoxSlotRecipeVariant } from '../../styled-system/recipes';
4
4
  import { type SearchBoxProps } from '../SearchBox';
5
5
  import { type PopoverProps } from '../Popover';
6
6
  import { type InfiniteScrollDirection } from '../utilities/dom/useInfiniteScroll';
7
+ import { type RenderOptionLabelSlot } from '../shared/OptionLabel';
7
8
  type AllowedValueTypes = string | number | undefined;
8
9
  export type VirtualOptionTypes = Pick<VirtualizerOptions<HTMLElement, Element>, 'estimateSize' | 'overscan'>;
9
10
  export type InfiniteScrollConfig = {
@@ -349,6 +350,38 @@ export type SelectBoxProps<T extends AllowedValueTypes = string, AdditionalProps
349
350
  * ```
350
351
  */
351
352
  renderOption?: (option: SelectBoxOption<T, AdditionalProps>, isSelected: boolean) => React.ReactNode;
353
+ /**
354
+ * The function to customize the label area of each option in the list, such as adding an icon or tags.
355
+ *
356
+ * Unlike `renderOption`, only the label text is replaced. The selected mark and the
357
+ * hover, focus, pressed, and disabled styles of the option are kept.
358
+ *
359
+ * - The `label` of the option is still required and is used for search, type-ahead, and the trigger.
360
+ * - The slot content becomes the accessible name of the option. Include the same words as `label`,
361
+ * as text or as the accessible name of an image, so that what users read matches what they can type.
362
+ * - Return only non-interactive content, because the option itself is the interactive element.
363
+ * - Ignored when `renderOption` is provided.
364
+ * - Define the function outside the component or wrap it in `useCallback`, so that the options are
365
+ * not re-rendered every time the parent renders.
366
+ *
367
+ * @param option - The option object being rendered (includes additional properties if defined)
368
+ *
369
+ * @param state.isSelected - Whether the option is currently selected
370
+ *
371
+ * @example
372
+ * ```tsx
373
+ * <SelectBox
374
+ * options={options}
375
+ * renderOptionLabelSlot={(option) => (
376
+ * <>
377
+ * <Icon aria-hidden />
378
+ * <span>{option.label}</span>
379
+ * </>
380
+ * )}
381
+ * />
382
+ * ```
383
+ */
384
+ renderOptionLabelSlot?: RenderOptionLabelSlot<SelectBoxOption<T, AdditionalProps>>;
352
385
  /**
353
386
  * The function to customize how the popover is rendered.
354
387
  * - `searchNode` is the search input element. It's available only when `enableSearchOptions` is `true`.
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { type BasedAdditionalProps, type SelectBoxOption } from './SelectBox.types';
2
+ import { type BasedAdditionalProps, type SelectBoxOption, type SelectBoxProps } from './SelectBox.types';
3
3
  type SelectBoxOptionProps<T extends string | number | undefined = string, AdditionalProps extends BasedAdditionalProps = BasedAdditionalProps> = {
4
4
  /**
5
5
  * The option data to render.
@@ -32,6 +32,10 @@ type SelectBoxOptionProps<T extends string | number | undefined = string, Additi
32
32
  * Custom render function for the option.
33
33
  */
34
34
  renderOption?: (option: SelectBoxOption<T, AdditionalProps>, isSelected: boolean) => React.ReactNode;
35
+ /**
36
+ * Custom render function for the label area of the option.
37
+ */
38
+ renderOptionLabelSlot?: SelectBoxProps<T, AdditionalProps>['renderOptionLabelSlot'];
35
39
  /**
36
40
  * Handler called when option receives focus.
37
41
  */
@@ -3,14 +3,14 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
3
3
  import { memo, useMemo, useCallback } from 'react';
4
4
  import { Current } from '@moneyforward/mfui-icons-react';
5
5
  import { FocusIndicator } from '../FocusIndicator';
6
- import { Typography } from '../Typography';
6
+ import { OptionLabel } from '../shared/OptionLabel';
7
7
  import { cx } from '../../styled-system/css';
8
8
  import { isOptionDisabled } from './utils/isSelectableOption';
9
9
  /**
10
10
  * Individual option component for SelectBox.
11
11
  * Memoized to prevent unnecessary re-renders when other options change.
12
12
  */
13
- function SelectBoxOptionComponentInner({ option, isSelected, tabbableOptionIndex, optionIndex, classes, groupLabelId, renderOption, onFocus, onClick, onKeyDown, }) {
13
+ function SelectBoxOptionComponentInner({ option, isSelected, tabbableOptionIndex, optionIndex, classes, groupLabelId, renderOption, renderOptionLabelSlot, onFocus, onClick, onKeyDown, }) {
14
14
  const isDisabled = isOptionDisabled(option);
15
15
  const tabIndex = useMemo(() => (isDisabled ? -1 : optionIndex === tabbableOptionIndex ? 0 : -1), [optionIndex, tabbableOptionIndex, isDisabled]);
16
16
  const handleFocus = useCallback(() => {
@@ -25,8 +25,7 @@ function SelectBoxOptionComponentInner({ option, isSelected, tabbableOptionIndex
25
25
  }, [onClick, option, isDisabled]);
26
26
  // Type-safe property access to satisfy eslint rules
27
27
  const optionValue = 'value' in option && option.value !== undefined ? String(option.value) : undefined;
28
- const optionLabel = option.label;
29
- return (_jsx(FocusIndicator, { position: "inside", children: _jsx("li", { role: "option", "data-value": optionValue, className: cx(classes.listItem, 'mfui-SelectBox__listItem'), "aria-selected": isSelected, "aria-disabled": isDisabled, "aria-describedby": groupLabelId, tabIndex: tabIndex, onFocus: handleFocus, onKeyDown: onKeyDown, onClick: handleClick, children: renderOption ? (renderOption(option, isSelected)) : (_jsxs(_Fragment, { children: [_jsx(Current, { "aria-hidden": true }), _jsx(Typography, { variant: "body", children: optionLabel })] })) }) }));
28
+ return (_jsx(FocusIndicator, { position: "inside", children: _jsx("li", { role: "option", "data-value": optionValue, className: cx(classes.listItem, 'mfui-SelectBox__listItem'), "aria-selected": isSelected, "aria-disabled": isDisabled, "aria-describedby": groupLabelId, tabIndex: tabIndex, onFocus: handleFocus, onKeyDown: onKeyDown, onClick: handleClick, children: renderOption ? (renderOption(option, isSelected)) : (_jsxs(_Fragment, { children: [_jsx(Current, { "aria-hidden": true }), _jsx(OptionLabel, { option: option, isSelected: isSelected, renderOptionLabelSlot: renderOptionLabelSlot, slotContentName: "select-box-option-label-slot" })] })) }) }));
30
29
  }
31
30
  export const SelectBoxOptionComponent = memo(SelectBoxOptionComponentInner);
32
31
  SelectBoxOptionComponent.displayName = 'SelectBoxOptionComponent';
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { type BasedAdditionalProps, type SelectBoxOption } from './SelectBox.types';
2
+ import { type BasedAdditionalProps, type SelectBoxOption, type SelectBoxProps } from './SelectBox.types';
3
3
  type SelectBoxOptionGroupProps<T extends string | number | undefined = string, AdditionalProps extends BasedAdditionalProps = BasedAdditionalProps> = {
4
4
  /**
5
5
  * The group option with nested options.
@@ -38,6 +38,10 @@ type SelectBoxOptionGroupProps<T extends string | number | undefined = string, A
38
38
  * Custom render function for options.
39
39
  */
40
40
  renderOption?: (option: SelectBoxOption<T, AdditionalProps>, isSelected: boolean) => React.ReactNode;
41
+ /**
42
+ * Custom render function for the label area of options.
43
+ */
44
+ renderOptionLabelSlot?: SelectBoxProps<T, AdditionalProps>['renderOptionLabelSlot'];
41
45
  /**
42
46
  * Handler called when option receives focus.
43
47
  */
@@ -8,7 +8,7 @@ import { SelectBoxOptionComponent } from './SelectBoxOption';
8
8
  * Renders a group label followed by its nested options.
9
9
  * Memoized to prevent unnecessary re-renders.
10
10
  */
11
- function SelectBoxOptionGroupInner({ groupOption, selectedOption, valueToIndexMap, tabbableOptionIndex, classes, groupLabelId, hasHiddenGroupLabel, renderOption, onOptionFocus, onOptionClick, onOptionKeyDown, findFlatOptionIndexByValue, }) {
11
+ function SelectBoxOptionGroupInner({ groupOption, selectedOption, valueToIndexMap, tabbableOptionIndex, classes, groupLabelId, hasHiddenGroupLabel, renderOption, renderOptionLabelSlot, onOptionFocus, onOptionClick, onOptionKeyDown, findFlatOptionIndexByValue, }) {
12
12
  // Type-safe property access to satisfy eslint rules
13
13
  const groupLabel = groupOption.label;
14
14
  const nestedOptions = groupOption.options;
@@ -23,7 +23,7 @@ function SelectBoxOptionGroupInner({ groupOption, selectedOption, valueToIndexMa
23
23
  const optionLabel = nestedOption.label;
24
24
  const keyValue = optionValue !== undefined ? String(optionValue) : `${optionLabel}-${String(index)}`;
25
25
  const isSelected = selectedOption && 'value' in selectedOption ? selectedOption.value === optionValue : false;
26
- return (_jsx(SelectBoxOptionComponent, { option: nestedOption, isSelected: isSelected, tabbableOptionIndex: tabbableOptionIndex, optionIndex: flatOptionIndex, classes: classes, groupLabelId: groupLabelId, renderOption: renderOption, onFocus: onOptionFocus, onClick: onOptionClick, onKeyDown: onOptionKeyDown(nestedOption) }, keyValue));
26
+ return (_jsx(SelectBoxOptionComponent, { option: nestedOption, isSelected: isSelected, tabbableOptionIndex: tabbableOptionIndex, optionIndex: flatOptionIndex, classes: classes, groupLabelId: groupLabelId, renderOption: renderOption, renderOptionLabelSlot: renderOptionLabelSlot, onFocus: onOptionFocus, onClick: onOptionClick, onKeyDown: onOptionKeyDown(nestedOption) }, keyValue));
27
27
  })] }));
28
28
  }
29
29
  export const SelectBoxOptionGroup = memo(SelectBoxOptionGroupInner);
@@ -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