@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.
- package/dist/src/DataTable/DataTable.d.ts +1 -1
- package/dist/src/DataTable/DataTable.js +59 -6
- package/dist/src/DataTable/DataTable.types.d.ts +101 -0
- package/dist/src/DataTable/DataTableBody/DataTableBody.d.ts +2 -1
- package/dist/src/DataTable/DataTableBody/DataTableBody.js +72 -8
- package/dist/src/DataTable/DataTableBody/DataTableBody.types.d.ts +81 -1
- package/dist/src/DataTable/DataTableBody/hooks/useVirtualizedRows.d.ts +64 -0
- package/dist/src/DataTable/DataTableBody/hooks/useVirtualizedRows.js +53 -0
- package/dist/src/DataTable/DataTableBody/index.d.ts +1 -1
- package/dist/src/DataTable/DataTableHeader/DataTableHeader.js +13 -2
- package/dist/src/DataTable/DataTableHeaderCell/DataTableHeaderCell.js +5 -2
- package/dist/src/DataTable/DataTableHeaderRow/DataTableHeaderRow.js +35 -16
- package/dist/src/DataTable/DataTableHeaderRow/DataTableHeaderRow.types.d.ts +9 -0
- package/dist/src/DataTable/DataTableProvider.d.ts +45 -3
- package/dist/src/DataTable/DataTableProvider.js +30 -3
- package/dist/src/DataTable/DataTableRow/DataTableRow.js +21 -12
- package/dist/src/DataTable/hooks/useVirtualizedColumns.d.ts +73 -0
- package/dist/src/DataTable/hooks/useVirtualizedColumns.js +109 -0
- package/dist/src/DataTable/index.d.ts +2 -2
- package/dist/src/DataTable/shared/commonStyles.d.ts +11 -0
- package/dist/src/DataTable/shared/commonStyles.js +11 -0
- package/dist/src/DataTable/utils/renderVirtualizedColumns.d.ts +42 -0
- package/dist/src/DataTable/utils/renderVirtualizedColumns.js +40 -0
- package/dist/src/DataTable/utils/resolveColumnWidths.d.ts +28 -0
- package/dist/src/DataTable/utils/resolveColumnWidths.js +41 -0
- package/dist/src/MultipleSelectBox/MultipleSelectBox.d.ts +1 -1
- package/dist/src/MultipleSelectBox/MultipleSelectBox.js +20 -8
- package/dist/src/MultipleSelectBox/MultipleSelectBox.types.d.ts +33 -0
- package/dist/src/MultipleSelectBox/hooks/useOptionKeyboardNavigation.d.ts +1 -0
- package/dist/src/MultipleSelectBox/hooks/useOptionKeyboardNavigation.js +23 -16
- package/dist/src/SelectBox/SelectBox.js +18 -7
- package/dist/src/SelectBox/SelectBox.types.d.ts +33 -0
- package/dist/src/SelectBox/SelectBoxOption.d.ts +5 -1
- package/dist/src/SelectBox/SelectBoxOption.js +3 -4
- package/dist/src/SelectBox/SelectBoxOptionGroup.d.ts +5 -1
- package/dist/src/SelectBox/SelectBoxOptionGroup.js +2 -2
- package/dist/src/SelectBox/hooks/useKeyboardNavigation.d.ts +1 -0
- package/dist/src/SelectBox/hooks/useKeyboardNavigation.js +29 -14
- package/dist/src/Tooltip/Tooltip.d.ts +6 -0
- package/dist/src/Tooltip/Tooltip.js +6 -0
- package/dist/src/shared/OptionLabel/OptionLabel.d.ts +45 -0
- package/dist/src/shared/OptionLabel/OptionLabel.js +9 -0
- package/dist/src/shared/OptionLabel/index.d.ts +1 -0
- package/dist/src/shared/OptionLabel/index.js +1 -0
- package/dist/styled-system/recipes/data-table-body-slot-recipe.d.ts +1 -1
- package/dist/styled-system/recipes/data-table-body-slot-recipe.js +4 -0
- package/dist/styled-system/recipes/data-table-slot-recipe.d.ts +5 -1
- package/dist/styled-system/recipes/data-table-slot-recipe.js +19 -1
- package/dist/styled-system/tokens/index.js +4 -0
- package/dist/styled-system/tokens/tokens.d.ts +1 -1
- package/dist/styles.css +40 -4
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- 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 =
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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:
|
|
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
|
-
//
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
}, [
|
|
41
|
+
setFocusedOption(null);
|
|
42
|
+
}, []);
|
|
43
43
|
// Handler for when an option receives focus
|
|
44
44
|
const handleOptionFocus = useCallback((optionIndex) => {
|
|
45
|
-
|
|
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 =
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
}, [
|
|
45
|
+
setFocusedOption(null);
|
|
46
|
+
}, []);
|
|
45
47
|
const handleOptionFocus = useCallback((optionIndex) => {
|
|
46
|
-
|
|
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
|