@altruist03/ui 0.1.0 → 0.2.1
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/assets/images/noData.webp +0 -0
- package/dist/index.js +15237 -650
- package/dist/index.js.map +1 -1
- package/dist/noData-XPRHKMFR.webp +0 -0
- package/package.json +21 -7
- package/src/ThemeProvider/ThemeContext.tsx +75 -0
- package/src/ThemeProvider/index.ts +7 -0
- package/src/assets/images/noData.webp +0 -0
- package/src/assets.d.ts +10 -0
- package/src/components/Accordion/Accordion.stories.tsx +64 -0
- package/src/components/Accordion/Accordion.tsx +219 -0
- package/src/components/Accordion/Accordion.types.ts +16 -0
- package/src/components/Avatar/Avatar.stories.tsx +304 -0
- package/src/components/Avatar/Avatar.tsx +211 -0
- package/src/components/Avatar/Avatar.types.ts +33 -0
- package/src/components/Badge/Badge.stories.tsx +143 -0
- package/src/components/Badge/Badge.tsx +138 -0
- package/src/components/Badge/Badge.types.ts +24 -0
- package/src/components/Breadcrumb/Breadcrumb.stories.tsx +114 -0
- package/src/components/Breadcrumb/Breadcrumb.tsx +120 -0
- package/src/components/Breadcrumb/Breadcrumb.types.ts +22 -0
- package/src/components/Button/Button.stories.tsx +457 -0
- package/src/components/Button/Button.tsx +202 -0
- package/src/components/Button/Button.types.ts +18 -0
- package/src/components/Checkbox/Checkbox.stories.tsx +254 -0
- package/src/components/Checkbox/Checkbox.tsx +352 -0
- package/src/components/Checkbox/Checkbox.types.ts +123 -0
- package/src/components/CloseButton/CloseButton.stories.tsx +100 -0
- package/src/components/CloseButton/CloseButton.tsx +117 -0
- package/src/components/CloseButton/CloseButton.types.ts +15 -0
- package/src/components/CollapsibleSearch/CollapsibleSearch.tsx +120 -0
- package/src/components/DataTable/DataTable.stories.tsx +233 -0
- package/src/components/DataTable/DataTable.tsx +968 -0
- package/src/components/DataTable/DataTable.types.ts +49 -0
- package/src/components/DatePicker/DatePicker.stories.tsx +341 -0
- package/src/components/DatePicker/DatePicker.tsx +669 -0
- package/src/components/DatePicker/DatePicker.types.ts +105 -0
- package/src/components/DatePicker/DateTimePicker.tsx +35 -0
- package/src/components/DateRangePicker/DateRangePicker.tsx +179 -0
- package/src/components/DateRangePicker/DateRangePicker.types.ts +34 -0
- package/src/components/Drawer/Drawer.stories.tsx +243 -0
- package/src/components/Drawer/Drawer.tsx +196 -0
- package/src/components/Drawer/Drawer.types.ts +60 -0
- package/src/components/FileUpload/FileUpload.stories.tsx +162 -0
- package/src/components/FileUpload/FileUpload.tsx +216 -0
- package/src/components/FileUpload/FileUpload.types.ts +80 -0
- package/src/components/FilterPopover/FilterPopover.stories.tsx +173 -0
- package/src/components/FilterPopover/FilterPopover.tsx +310 -0
- package/src/components/FilterPopover/FilterPopover.types.ts +26 -0
- package/src/components/ImageViewer/ImageViewerToolbar.jsx +81 -0
- package/src/components/ImageViewer/useImagePanRotate.js +102 -0
- package/src/components/IpWhitelistField/IpWhitelistField.stories.tsx +138 -0
- package/src/components/IpWhitelistField/IpWhitelistField.tsx +164 -0
- package/src/components/IpWhitelistField/IpWhitelistField.types.ts +42 -0
- package/src/components/Loader/Loader.stories.tsx +95 -0
- package/src/components/Loader/Loader.tsx +132 -0
- package/src/components/Loader/Loader.types.ts +6 -0
- package/src/components/Menu/Menu.tsx +143 -0
- package/src/components/Menu/Menu.types.ts +28 -0
- package/src/components/MenuButton/MenuButton.stories.tsx +140 -0
- package/src/components/MenuButton/MenuButton.tsx +45 -0
- package/src/components/MenuButton/MenuButton.types.ts +21 -0
- package/src/components/Modal/Modal.stories.tsx +266 -0
- package/src/components/Modal/Modal.tsx +399 -0
- package/src/components/Modal/index.ts +2 -0
- package/src/components/Modal/types.ts +126 -0
- package/src/components/Pagination/Pagination.stories.tsx +167 -0
- package/src/components/Pagination/Pagination.tsx +266 -0
- package/src/components/Pagination/Pagination.types.ts +20 -0
- package/src/components/PeriodSelector/PeriodSelector.stories.tsx +50 -0
- package/src/components/PeriodSelector/PeriodSelector.tsx +47 -0
- package/src/components/PeriodSelector/PeriodSelector.types.ts +10 -0
- package/src/components/PhoneField/PhoneField.stories.tsx +141 -0
- package/src/components/PhoneField/PhoneField.tsx +284 -0
- package/src/components/PhoneField/PhoneField.types.ts +89 -0
- package/src/components/PhoneField/countryCodes.ts +236 -0
- package/src/components/Progress/Progress.stories.tsx +83 -0
- package/src/components/Progress/Progress.tsx +91 -0
- package/src/components/Progress/Progress.types.ts +22 -0
- package/src/components/RadioButton/RadioButton.stories.tsx +332 -0
- package/src/components/RadioButton/RadioButton.tsx +427 -0
- package/src/components/RadioButton/RadioButton.types.ts +117 -0
- package/src/components/ReportCard/ReportCard.tsx +183 -0
- package/src/components/ReportCard/ReportCard.types.ts +17 -0
- package/src/components/SectionIcon/SectionIcon.tsx +87 -0
- package/src/components/SectionIcon/SectionIcon.types.ts +23 -0
- package/src/components/SegmentedInputField/SegmentedInputField.stories.tsx +165 -0
- package/src/components/SegmentedInputField/SegmentedInputField.tsx +174 -0
- package/src/components/SegmentedInputField/SegmentedInputField.types.ts +51 -0
- package/src/components/SelectScroll/SelectScroll.stories.tsx +336 -0
- package/src/components/SelectScroll/SelectScroll.tsx +1119 -0
- package/src/components/SelectScroll/SelectScroll.types.ts +60 -0
- package/src/components/Selector/Selector.stories.tsx +382 -0
- package/src/components/Selector/Selector.tsx +1310 -0
- package/src/components/Selector/Selector.types.ts +71 -0
- package/src/components/Skeleton/Skeleton.stories.tsx +133 -0
- package/src/components/Skeleton/Skeleton.tsx +20 -0
- package/src/components/Skeleton/Skeleton.types.ts +3 -0
- package/src/components/Spinner/Spinner.stories.tsx +84 -0
- package/src/components/Spinner/Spinner.tsx +79 -0
- package/src/components/Spinner/Spinner.types.ts +8 -0
- package/src/components/StatCard/StatCard.stories.tsx +125 -0
- package/src/components/StatCard/StatCard.tsx +167 -0
- package/src/components/StatCard/StatCard.types.ts +20 -0
- package/src/components/StatusBadge/StatusBadge.stories.tsx +179 -0
- package/src/components/StatusBadge/StatusBadge.tsx +74 -0
- package/src/components/StatusBadge/StatusBadge.types.ts +23 -0
- package/src/components/Stepper/Stepper.stories.tsx +85 -0
- package/src/components/Stepper/Stepper.tsx +138 -0
- package/src/components/Stepper/Stepper.types.ts +16 -0
- package/src/components/SubTabs/SubTabs.stories.tsx +128 -0
- package/src/components/SubTabs/SubTabs.tsx +279 -0
- package/src/components/SubTabs/SubTabs.types.ts +44 -0
- package/src/components/SummaryCard/SummaryCard.stories.tsx +100 -0
- package/src/components/SummaryCard/SummaryCard.tsx +141 -0
- package/src/components/SummaryCard/SummaryCard.types.ts +22 -0
- package/src/components/Switch/Switch.stories.tsx +59 -0
- package/src/components/Switch/Switch.tsx +217 -0
- package/src/components/Switch/Switch.types.ts +22 -0
- package/src/components/Tabs/Tabs.stories.tsx +178 -0
- package/src/components/Tabs/Tabs.tsx +197 -0
- package/src/components/Tabs/Tabs.types.ts +34 -0
- package/src/components/TextField/TextField.stories.tsx +249 -0
- package/src/components/TextField/TextField.tsx +462 -0
- package/src/components/TextField/TextField.types.ts +141 -0
- package/src/components/Toast/Toast.stories.tsx +237 -0
- package/src/components/Toast/Toast.tsx +246 -0
- package/src/components/Toast/Toast.types.ts +31 -0
- package/src/components/ToggleButton/ToggleButton.stories.tsx +105 -0
- package/src/components/ToggleButton/ToggleButton.tsx +267 -0
- package/src/components/ToggleButton/ToggleButton.types.ts +19 -0
- package/src/components/UploadDropzone/UploadDropzone.jsx +78 -0
- package/src/components/UploadDropzone2/UploadDropzone.stories.tsx +94 -0
- package/src/components/UploadDropzone2/UploadDropzone.tsx +222 -0
- package/src/components/UploadDropzone2/UploadDropzone.types.ts +54 -0
- package/src/components/ViewEditToggle/ViewEditToggle.stories.tsx +40 -0
- package/src/components/ViewEditToggle/ViewEditToggle.tsx +239 -0
- package/src/components/ViewEditToggle/ViewEditToggle.types.ts +27 -0
- package/src/components/index.ts +157 -0
- package/src/icons/ReportDollarIcon.tsx +71 -0
- package/src/icons/index.ts +27 -0
- package/src/index.ts +214 -0
- package/src/storybook/preview.tsx +64 -0
- package/src/theme/index.ts +4 -0
- package/src/theme/types.d.ts +1 -0
- package/src/themes/cpc.ts +4 -0
- package/src/themes/firebird.ts +4 -0
- package/src/tokens/brands/cpc/themes/dark.json +82 -0
- package/src/tokens/brands/cpc/themes/light.json +1 -0
- package/src/tokens/brands/default/brand.json +20 -0
- package/src/tokens/brands/firebird/themes/dark.json +28 -0
- package/src/tokens/brands/firebird/themes/light.json +20 -0
- package/src/tokens/button.ts +24 -0
- package/src/tokens/components/accordion.json +52 -0
- package/src/tokens/components/avatar.json +40 -0
- package/src/tokens/components/badge.json +41 -0
- package/src/tokens/components/breadcrumb.json +13 -0
- package/src/tokens/components/button.json +67 -0
- package/src/tokens/components/card.json +39 -0
- package/src/tokens/components/checkbox.json +64 -0
- package/src/tokens/components/closebutton.json +55 -0
- package/src/tokens/components/datatable.json +40 -0
- package/src/tokens/components/datepicker.json +99 -0
- package/src/tokens/components/drawer.json +23 -0
- package/src/tokens/components/field.json +29 -0
- package/src/tokens/components/fileupload.json +57 -0
- package/src/tokens/components/filterpopover.json +29 -0
- package/src/tokens/components/input.json +31 -0
- package/src/tokens/components/ipwhitelistfield.json +16 -0
- package/src/tokens/components/loader.json +15 -0
- package/src/tokens/components/menu.json +21 -0
- package/src/tokens/components/modal.json +25 -0
- package/src/tokens/components/pagination.json +45 -0
- package/src/tokens/components/phonefield.json +41 -0
- package/src/tokens/components/progress.json +12 -0
- package/src/tokens/components/radio.json +87 -0
- package/src/tokens/components/reportcard.json +17 -0
- package/src/tokens/components/selector.json +87 -0
- package/src/tokens/components/selectscroll.json +65 -0
- package/src/tokens/components/skeleton.json +6 -0
- package/src/tokens/components/stepper.json +38 -0
- package/src/tokens/components/subtabs.json +45 -0
- package/src/tokens/components/switch.json +55 -0
- package/src/tokens/components/tabs.json +38 -0
- package/src/tokens/components/textfield.json +59 -0
- package/src/tokens/components/togglebutton.json +48 -0
- package/src/tokens/components/uploaddropzone.json +61 -0
- package/src/tokens/drawer.ts +37 -0
- package/src/tokens/foundation/breakpoints.json +10 -0
- package/src/tokens/foundation/colors.json +122 -0
- package/src/tokens/foundation/opacity.json +16 -0
- package/src/tokens/foundation/radius.json +14 -0
- package/src/tokens/foundation/size.json +38 -0
- package/src/tokens/foundation/spacing.json +19 -0
- package/src/tokens/foundation/typography.json +43 -0
- package/src/tokens/index.ts +3665 -0
- package/src/tokens/modal.ts +42 -0
- package/src/tokens/semantic/color.json +148 -0
- package/src/tokens/semantic/field.json +28 -0
- package/src/tokens/semantic/opacity.json +5 -0
- package/src/tokens/semantic/size.json +722 -0
- package/src/tokens/semantic/spacing.json +27 -0
- package/src/tokens/semantic/typography.json +87 -0
- package/src/utils/scrollLock.ts +44 -0
- package/dist/index.css +0 -514
- package/dist/index.css.map +0 -1
- package/dist/index.d.ts +0 -289
|
@@ -0,0 +1,1119 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect, useLayoutEffect, useCallback, useMemo } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { getSelectScrollTokensFromResolved } from '../../tokens';
|
|
4
|
+
import { useTokens } from '../../ThemeProvider/ThemeContext';
|
|
5
|
+
import { Checkbox } from '../Checkbox/Checkbox';
|
|
6
|
+
import { lockScroll, unlockScroll } from '../../utils/scrollLock';
|
|
7
|
+
|
|
8
|
+
const LIST_MAX_HEIGHT = 300;
|
|
9
|
+
const NO_OPTIONS_TEXT = 'No options';
|
|
10
|
+
|
|
11
|
+
export type SelectScrollOption = string | { label?: string; [key: string]: unknown };
|
|
12
|
+
|
|
13
|
+
export interface SelectScrollProps<T = SelectScrollOption> {
|
|
14
|
+
placeholder?: string;
|
|
15
|
+
options?: T[];
|
|
16
|
+
isDisabled?: boolean;
|
|
17
|
+
onChange?: (value: T | null) => void;
|
|
18
|
+
value?: T | null;
|
|
19
|
+
required?: boolean;
|
|
20
|
+
isOptionDisabled?: (option: T) => boolean;
|
|
21
|
+
loading?: boolean;
|
|
22
|
+
onScroll?: (e: React.UIEvent<HTMLUListElement>) => void;
|
|
23
|
+
/** Called when list is scrolled near bottom (e.g. to load next page). Pass your API/loadMore function from parent. */
|
|
24
|
+
onLoadMore?: () => void;
|
|
25
|
+
/** API method to load more: (pageNum, append) => void. Use with loadMorePage, onLoadMorePageChange, loadMoreHasMore. */
|
|
26
|
+
loadMoreMethod?: (pageNum: number, append: boolean) => void;
|
|
27
|
+
/** Current page when using loadMoreMethod (parent state). */
|
|
28
|
+
loadMorePage?: number;
|
|
29
|
+
/** Callback when page changes (e.g. setPage from parent). */
|
|
30
|
+
onLoadMorePageChange?: (nextPage: number) => void;
|
|
31
|
+
/** Whether more data is available when using loadMoreMethod. */
|
|
32
|
+
loadMoreHasMore?: boolean;
|
|
33
|
+
label?: string;
|
|
34
|
+
size?: 'sm' | 'md' | 'lg';
|
|
35
|
+
getOptionLabel?: (option: T) => string;
|
|
36
|
+
isInvalid?: boolean;
|
|
37
|
+
error?: string;
|
|
38
|
+
/** Shown below input when not in error state (when isInvalid is false). */
|
|
39
|
+
helperText?: string;
|
|
40
|
+
renderOption?: (option: T, state: { index: number }) => React.ReactNode;
|
|
41
|
+
onInputChange?: (value: string) => void;
|
|
42
|
+
customRenderOptionView?: (option: T) => React.ReactNode;
|
|
43
|
+
onBlur?: (e: React.FocusEvent) => void;
|
|
44
|
+
id?: string;
|
|
45
|
+
noOptionsText?: string;
|
|
46
|
+
loadingText?: string;
|
|
47
|
+
style?: React.CSSProperties;
|
|
48
|
+
/** Custom filter: (options, { inputValue, getOptionLabel }) => filtered options. When not provided, filters by input matching option label. */
|
|
49
|
+
filterOptions?: (
|
|
50
|
+
options: T[],
|
|
51
|
+
state: { inputValue: string; getOptionLabel: (option: T) => string }
|
|
52
|
+
) => T[];
|
|
53
|
+
/**
|
|
54
|
+
* When true (default), options are treated as server-driven: no client-side label filtering.
|
|
55
|
+
* Use `onApiSearch` for debounced backend search; pair with `onInputChange` if you need a ref to the latest typed value for scroll/load-more.
|
|
56
|
+
*/
|
|
57
|
+
isApiSearch?: boolean;
|
|
58
|
+
/** Debounce (ms) before calling `onApiSearch` after typing. Default 1000. Ignored when `isApiSearch` is false or `onApiSearch` is omitted. */
|
|
59
|
+
apiSearchDebounceMs?: number;
|
|
60
|
+
/** Called after debounce with the trimmed search string. Reset lists and fetch page 0 in the parent. */
|
|
61
|
+
onApiSearch?: (searchTerm: string) => void;
|
|
62
|
+
/** Background color variant */
|
|
63
|
+
color?: 'grey' | 'white';
|
|
64
|
+
/** Fires after mount is skipped; use for lazy load when `true` and cleanup when `false` (e.g. restore server list). */
|
|
65
|
+
onOpenChange?: (open: boolean) => void;
|
|
66
|
+
/** Shows clear (X) action and allows clearing when true. */
|
|
67
|
+
clearable?: boolean;
|
|
68
|
+
/** Custom equality for highlighting the selected option. Defaults to matching `value`/`id`, then label. */
|
|
69
|
+
isOptionEqual?: (option: T, value: T) => boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getOptionLabelDefault(option: SelectScrollOption): string {
|
|
73
|
+
if (typeof option === 'string') return option;
|
|
74
|
+
return (option as { label?: string }).label ?? String(option);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getOptionValueKey(option: SelectScrollOption): string | null {
|
|
78
|
+
if (option == null) return null;
|
|
79
|
+
if (typeof option !== 'object') return String(option);
|
|
80
|
+
const record = option as { value?: unknown; id?: unknown };
|
|
81
|
+
if (record.value != null && record.value !== '') return String(record.value);
|
|
82
|
+
if (record.id != null && record.id !== '') return String(record.id);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function areSelectOptionsEqual<T>(
|
|
87
|
+
option: T,
|
|
88
|
+
selected: T,
|
|
89
|
+
getOptionLabel: (option: T) => string
|
|
90
|
+
): boolean {
|
|
91
|
+
if (option === selected) return true;
|
|
92
|
+
if (typeof option !== 'object' || typeof selected !== 'object' || !option || !selected) {
|
|
93
|
+
return getOptionLabel(option) === getOptionLabel(selected);
|
|
94
|
+
}
|
|
95
|
+
const optionKey = getOptionValueKey(option as SelectScrollOption);
|
|
96
|
+
const selectedKey = getOptionValueKey(selected as SelectScrollOption);
|
|
97
|
+
if (optionKey != null && selectedKey != null) return optionKey === selectedKey;
|
|
98
|
+
return getOptionLabel(option) === getOptionLabel(selected);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Memoized option row to avoid re-rendering all items when list updates
|
|
102
|
+
interface OptionItemProps<T = SelectScrollOption> {
|
|
103
|
+
option: T;
|
|
104
|
+
optionLabel: string;
|
|
105
|
+
disabled: boolean;
|
|
106
|
+
selected: boolean;
|
|
107
|
+
index: number;
|
|
108
|
+
onSelect: (option: T) => void;
|
|
109
|
+
renderOption?: (option: T, state: { index: number }) => React.ReactNode;
|
|
110
|
+
customRenderOptionView?: (option: T) => React.ReactNode;
|
|
111
|
+
scrollTokens: ReturnType<typeof getSelectScrollTokensFromResolved>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function OptionItemInner<T extends SelectScrollOption>(props: OptionItemProps<T>) {
|
|
115
|
+
const {
|
|
116
|
+
option,
|
|
117
|
+
optionLabel,
|
|
118
|
+
disabled,
|
|
119
|
+
selected,
|
|
120
|
+
index,
|
|
121
|
+
onSelect,
|
|
122
|
+
renderOption,
|
|
123
|
+
customRenderOptionView,
|
|
124
|
+
scrollTokens: tokens,
|
|
125
|
+
} = props;
|
|
126
|
+
|
|
127
|
+
const handleMouseDown = useCallback(
|
|
128
|
+
(e: React.MouseEvent) => {
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
onSelect(option);
|
|
131
|
+
},
|
|
132
|
+
[option, onSelect]
|
|
133
|
+
);
|
|
134
|
+
const handleMouseEnter = useCallback(
|
|
135
|
+
(e: React.MouseEvent<HTMLLIElement>) => {
|
|
136
|
+
if (!disabled) e.currentTarget.style.backgroundColor = tokens.optionHoverBackground;
|
|
137
|
+
},
|
|
138
|
+
[disabled, tokens.optionHoverBackground]
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLLIElement>) => {
|
|
142
|
+
e.currentTarget.style.backgroundColor = 'transparent';
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
const itemStyle = useMemo(
|
|
146
|
+
() => ({
|
|
147
|
+
padding: tokens.optionPadding,
|
|
148
|
+
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
149
|
+
opacity: disabled ? 0.6 : 1,
|
|
150
|
+
backgroundColor: 'transparent',
|
|
151
|
+
fontSize: tokens.optionFontSize,
|
|
152
|
+
color: tokens.optionColor,
|
|
153
|
+
listStyle: 'none' as const,
|
|
154
|
+
display: 'flex',
|
|
155
|
+
alignItems: 'center',
|
|
156
|
+
justifyContent: 'space-between',
|
|
157
|
+
}),
|
|
158
|
+
[disabled, tokens.optionPadding, tokens.optionFontSize, tokens.optionColor]
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<li
|
|
163
|
+
role="option"
|
|
164
|
+
aria-disabled={disabled}
|
|
165
|
+
aria-selected={selected}
|
|
166
|
+
onMouseDown={handleMouseDown}
|
|
167
|
+
onMouseEnter={handleMouseEnter}
|
|
168
|
+
onMouseLeave={handleMouseLeave}
|
|
169
|
+
style={itemStyle}
|
|
170
|
+
>
|
|
171
|
+
<span>
|
|
172
|
+
{renderOption ?
|
|
173
|
+
renderOption(option, { index })
|
|
174
|
+
: customRenderOptionView ?
|
|
175
|
+
customRenderOptionView(option)
|
|
176
|
+
: optionLabel}
|
|
177
|
+
</span>
|
|
178
|
+
{selected && (
|
|
179
|
+
<div style={{ marginLeft: 8, flexShrink: 0 }}>
|
|
180
|
+
<Checkbox
|
|
181
|
+
label=""
|
|
182
|
+
checked={selected}
|
|
183
|
+
disabled={disabled}
|
|
184
|
+
onChange={() => onSelect(option)}
|
|
185
|
+
id={`select-scroll-checkbox-${index}`}
|
|
186
|
+
/>
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
</li>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const OptionItem = React.memo(OptionItemInner) as typeof OptionItemInner;
|
|
194
|
+
|
|
195
|
+
function SelectScrollInner<T extends SelectScrollOption = SelectScrollOption>(
|
|
196
|
+
props: SelectScrollProps<T>
|
|
197
|
+
) {
|
|
198
|
+
const {
|
|
199
|
+
placeholder = 'Select...',
|
|
200
|
+
options: optionsProp,
|
|
201
|
+
isDisabled = false,
|
|
202
|
+
onChange,
|
|
203
|
+
value,
|
|
204
|
+
required = false,
|
|
205
|
+
isOptionDisabled,
|
|
206
|
+
loading: loadingProp = false,
|
|
207
|
+
onScroll: onScrollProp,
|
|
208
|
+
onLoadMore: onLoadMoreProp,
|
|
209
|
+
loadMoreMethod: loadMoreMethodProp,
|
|
210
|
+
loadMorePage,
|
|
211
|
+
onLoadMorePageChange,
|
|
212
|
+
loadMoreHasMore = true,
|
|
213
|
+
label,
|
|
214
|
+
size = 'md',
|
|
215
|
+
getOptionLabel = getOptionLabelDefault as (option: T) => string,
|
|
216
|
+
isInvalid = false,
|
|
217
|
+
error,
|
|
218
|
+
helperText,
|
|
219
|
+
renderOption,
|
|
220
|
+
onInputChange,
|
|
221
|
+
customRenderOptionView,
|
|
222
|
+
onBlur,
|
|
223
|
+
id,
|
|
224
|
+
noOptionsText = NO_OPTIONS_TEXT,
|
|
225
|
+
loadingText = 'Fetching more data...',
|
|
226
|
+
style,
|
|
227
|
+
filterOptions: filterOptionsProp,
|
|
228
|
+
color = 'grey',
|
|
229
|
+
onOpenChange,
|
|
230
|
+
clearable = true,
|
|
231
|
+
isApiSearch = true,
|
|
232
|
+
apiSearchDebounceMs = 1000,
|
|
233
|
+
onApiSearch,
|
|
234
|
+
isOptionEqual,
|
|
235
|
+
} = props;
|
|
236
|
+
|
|
237
|
+
const onOpenChangeRef = useRef(onOpenChange);
|
|
238
|
+
onOpenChangeRef.current = onOpenChange;
|
|
239
|
+
const openChangeSkipMountRef = useRef(true);
|
|
240
|
+
|
|
241
|
+
const [inputValue, setInputValue] = useState('');
|
|
242
|
+
const [isTyping, setIsTyping] = useState(false);
|
|
243
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
244
|
+
const [isHovering, setIsHovering] = useState(false);
|
|
245
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
246
|
+
const [listboxPosition, setListboxPosition] = useState<{
|
|
247
|
+
top: number;
|
|
248
|
+
left: number;
|
|
249
|
+
width: number;
|
|
250
|
+
placement: 'up' | 'down';
|
|
251
|
+
} | null>(null);
|
|
252
|
+
const hasListboxPosition = Boolean(listboxPosition);
|
|
253
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
254
|
+
// Used for dropdown positioning (must be the input area, not the whole component including label).
|
|
255
|
+
const fieldAnchorRef = useRef<HTMLDivElement>(null);
|
|
256
|
+
const listRef = useRef<HTMLUListElement | null>(null);
|
|
257
|
+
const didSelectRef = useRef(false);
|
|
258
|
+
const lastSelectedLabelRef = useRef<string>('');
|
|
259
|
+
const mouseDownOnFieldRef = useRef(false);
|
|
260
|
+
const listboxPositionRafRef = useRef<number | null>(null);
|
|
261
|
+
const apiSearchInputRef = useRef('');
|
|
262
|
+
const apiSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
263
|
+
/** Tracks previous `value` so we only clear the input when a real selection was cleared, not on every re-render while searching (unstable `getOptionLabel` + `value === null`). */
|
|
264
|
+
const prevControlledValueRef = useRef<T | null | undefined>(undefined);
|
|
265
|
+
const getOptionLabelRef = useRef(getOptionLabel);
|
|
266
|
+
getOptionLabelRef.current = getOptionLabel;
|
|
267
|
+
|
|
268
|
+
const clearApiSearchTimer = useCallback(() => {
|
|
269
|
+
if (apiSearchTimerRef.current) {
|
|
270
|
+
clearTimeout(apiSearchTimerRef.current);
|
|
271
|
+
apiSearchTimerRef.current = null;
|
|
272
|
+
}
|
|
273
|
+
}, []);
|
|
274
|
+
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
return () => {
|
|
277
|
+
clearApiSearchTimer();
|
|
278
|
+
};
|
|
279
|
+
}, [clearApiSearchTimer]);
|
|
280
|
+
|
|
281
|
+
// Prevent background/page vertical scrolling while the dropdown list is open.
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
if (!isOpen) return;
|
|
284
|
+
if (typeof document === 'undefined' || typeof window === 'undefined') return;
|
|
285
|
+
|
|
286
|
+
lockScroll();
|
|
287
|
+
|
|
288
|
+
return () => {
|
|
289
|
+
unlockScroll();
|
|
290
|
+
};
|
|
291
|
+
}, [isOpen]);
|
|
292
|
+
|
|
293
|
+
const options = useMemo(() => optionsProp ?? [], [optionsProp]);
|
|
294
|
+
const loading = !!loadingProp;
|
|
295
|
+
|
|
296
|
+
const useClientFilter = !isApiSearch;
|
|
297
|
+
const searchString = useClientFilter && isTyping ? inputValue.toLowerCase() : '';
|
|
298
|
+
|
|
299
|
+
const filterFn = useCallback(
|
|
300
|
+
(option: T) => {
|
|
301
|
+
const labelStr = getOptionLabel(option).toLowerCase();
|
|
302
|
+
return labelStr.includes(searchString);
|
|
303
|
+
},
|
|
304
|
+
[searchString, getOptionLabel]
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const filteredOptions = useMemo(() => {
|
|
308
|
+
if (isApiSearch) {
|
|
309
|
+
return filterOptionsProp ?
|
|
310
|
+
filterOptionsProp(options, {
|
|
311
|
+
inputValue: '',
|
|
312
|
+
getOptionLabel,
|
|
313
|
+
})
|
|
314
|
+
: options;
|
|
315
|
+
}
|
|
316
|
+
return filterOptionsProp ?
|
|
317
|
+
filterOptionsProp(options, {
|
|
318
|
+
inputValue: searchString,
|
|
319
|
+
getOptionLabel,
|
|
320
|
+
})
|
|
321
|
+
: options.filter(filterFn);
|
|
322
|
+
}, [isApiSearch, filterOptionsProp, options, searchString, getOptionLabel, filterFn]);
|
|
323
|
+
const noDataFound = useMemo(
|
|
324
|
+
() => filteredOptions.length === 0 && !loading,
|
|
325
|
+
[filteredOptions.length, loading]
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const resolvedTokens = useTokens();
|
|
329
|
+
const scrollTokens = getSelectScrollTokensFromResolved(resolvedTokens);
|
|
330
|
+
const listboxGapPx = useMemo(() => {
|
|
331
|
+
const raw = resolvedTokens?.foundation?.spacing?.['1'];
|
|
332
|
+
const n =
|
|
333
|
+
typeof raw === 'string' ? parseInt(raw.replace(/px$/i, ''), 10)
|
|
334
|
+
: typeof raw === 'number' ? raw
|
|
335
|
+
: NaN;
|
|
336
|
+
return Number.isFinite(n) ? n : 0;
|
|
337
|
+
}, [resolvedTokens]);
|
|
338
|
+
|
|
339
|
+
const updateListboxPosition = useCallback(() => {
|
|
340
|
+
const anchor = fieldAnchorRef.current;
|
|
341
|
+
if (!anchor) return;
|
|
342
|
+
|
|
343
|
+
const rect = anchor.getBoundingClientRect();
|
|
344
|
+
const viewportHeight = Math.max(0, window.innerHeight || document.documentElement.clientHeight);
|
|
345
|
+
|
|
346
|
+
const spaceBelow = viewportHeight - rect.bottom;
|
|
347
|
+
const spaceAbove = rect.top;
|
|
348
|
+
|
|
349
|
+
// Prefer the real list height whenever the list is mounted so placement matches
|
|
350
|
+
// 1-row vs full lists (avoids choosing "up" using 300px then jumping when measured).
|
|
351
|
+
const measuredHeight =
|
|
352
|
+
listRef.current ?
|
|
353
|
+
[
|
|
354
|
+
listRef.current.clientHeight,
|
|
355
|
+
listRef.current.offsetHeight,
|
|
356
|
+
listRef.current.getBoundingClientRect().height,
|
|
357
|
+
].find((h) => typeof h === 'number' && h > 0)
|
|
358
|
+
: undefined;
|
|
359
|
+
const heightToUse =
|
|
360
|
+
typeof measuredHeight === 'number' && measuredHeight > 0 ? measuredHeight : LIST_MAX_HEIGHT;
|
|
361
|
+
|
|
362
|
+
const neededHeight = heightToUse;
|
|
363
|
+
|
|
364
|
+
let placement: 'up' | 'down';
|
|
365
|
+
if (spaceBelow >= neededHeight + listboxGapPx) {
|
|
366
|
+
placement = 'down';
|
|
367
|
+
} else if (spaceAbove >= neededHeight + listboxGapPx) {
|
|
368
|
+
placement = 'up';
|
|
369
|
+
} else {
|
|
370
|
+
// If both sides are insufficient, pick whichever has more room.
|
|
371
|
+
placement = spaceBelow >= spaceAbove ? 'down' : 'up';
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
let top: number;
|
|
375
|
+
const maxTop = viewportHeight - heightToUse;
|
|
376
|
+
if (placement === 'down') {
|
|
377
|
+
const desiredTop = rect.bottom + listboxGapPx;
|
|
378
|
+
top = Math.max(0, Math.min(desiredTop, maxTop));
|
|
379
|
+
} else {
|
|
380
|
+
const desiredTop = rect.top - listboxGapPx - heightToUse;
|
|
381
|
+
top = Math.max(0, Math.min(desiredTop, maxTop));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
setListboxPosition({
|
|
385
|
+
top,
|
|
386
|
+
left: rect.left,
|
|
387
|
+
width: rect.width,
|
|
388
|
+
placement,
|
|
389
|
+
});
|
|
390
|
+
}, [listboxGapPx]);
|
|
391
|
+
|
|
392
|
+
const scheduleListboxPositionUpdate = useCallback(() => {
|
|
393
|
+
if (listboxPositionRafRef.current != null) {
|
|
394
|
+
cancelAnimationFrame(listboxPositionRafRef.current);
|
|
395
|
+
}
|
|
396
|
+
listboxPositionRafRef.current = requestAnimationFrame(() => {
|
|
397
|
+
listboxPositionRafRef.current = null;
|
|
398
|
+
updateListboxPosition();
|
|
399
|
+
});
|
|
400
|
+
}, [updateListboxPosition]);
|
|
401
|
+
|
|
402
|
+
const setListRef = useCallback(
|
|
403
|
+
(node: HTMLUListElement | null) => {
|
|
404
|
+
listRef.current = node;
|
|
405
|
+
if (node && isOpen) {
|
|
406
|
+
// When the portal/list mounts, immediately re-position using the real list height.
|
|
407
|
+
scheduleListboxPositionUpdate();
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
[isOpen, scheduleListboxPositionUpdate]
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
const handleListScroll = useCallback(
|
|
414
|
+
(e: React.UIEvent<HTMLUListElement>) => {
|
|
415
|
+
const list = e.currentTarget;
|
|
416
|
+
const nearBottom = list.scrollHeight - list.scrollTop <= list.clientHeight + 50;
|
|
417
|
+
if (nearBottom) {
|
|
418
|
+
if (
|
|
419
|
+
loadMoreMethodProp &&
|
|
420
|
+
loadMorePage != null &&
|
|
421
|
+
onLoadMorePageChange &&
|
|
422
|
+
loadMoreHasMore &&
|
|
423
|
+
!loadingProp
|
|
424
|
+
) {
|
|
425
|
+
const nextPage = loadMorePage + 1;
|
|
426
|
+
loadMoreMethodProp(nextPage, true);
|
|
427
|
+
onLoadMorePageChange(nextPage);
|
|
428
|
+
} else {
|
|
429
|
+
onLoadMoreProp?.();
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
onScrollProp?.(e);
|
|
433
|
+
},
|
|
434
|
+
[
|
|
435
|
+
onScrollProp,
|
|
436
|
+
onLoadMoreProp,
|
|
437
|
+
loadMoreMethodProp,
|
|
438
|
+
loadMorePage,
|
|
439
|
+
onLoadMorePageChange,
|
|
440
|
+
loadMoreHasMore,
|
|
441
|
+
loadingProp,
|
|
442
|
+
]
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
const handleInputChange = useCallback(
|
|
446
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
447
|
+
const v = e.target.value;
|
|
448
|
+
setInputValue(v);
|
|
449
|
+
setIsTyping(true);
|
|
450
|
+
setIsOpen(true);
|
|
451
|
+
onInputChange?.(v);
|
|
452
|
+
|
|
453
|
+
if (clearable && v === '' && value != null) {
|
|
454
|
+
lastSelectedLabelRef.current = '';
|
|
455
|
+
prevControlledValueRef.current = null;
|
|
456
|
+
onChange?.(null);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (isApiSearch && onApiSearch) {
|
|
460
|
+
apiSearchInputRef.current = v;
|
|
461
|
+
clearApiSearchTimer();
|
|
462
|
+
apiSearchTimerRef.current = setTimeout(() => {
|
|
463
|
+
apiSearchTimerRef.current = null;
|
|
464
|
+
const term = (apiSearchInputRef.current || '').trim();
|
|
465
|
+
onApiSearch(term);
|
|
466
|
+
}, apiSearchDebounceMs);
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
[
|
|
470
|
+
onInputChange,
|
|
471
|
+
isApiSearch,
|
|
472
|
+
onApiSearch,
|
|
473
|
+
apiSearchDebounceMs,
|
|
474
|
+
clearApiSearchTimer,
|
|
475
|
+
clearable,
|
|
476
|
+
value,
|
|
477
|
+
onChange,
|
|
478
|
+
]
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const handleSelect = useCallback(
|
|
482
|
+
(option: T) => {
|
|
483
|
+
if (isOptionDisabled?.(option)) return;
|
|
484
|
+
const label = getOptionLabel(option);
|
|
485
|
+
didSelectRef.current = true;
|
|
486
|
+
lastSelectedLabelRef.current = label;
|
|
487
|
+
if (isApiSearch && onApiSearch) {
|
|
488
|
+
clearApiSearchTimer();
|
|
489
|
+
}
|
|
490
|
+
setInputValue(label);
|
|
491
|
+
setIsTyping(false);
|
|
492
|
+
setIsOpen(false);
|
|
493
|
+
onChange?.(option);
|
|
494
|
+
setTimeout(() => {
|
|
495
|
+
didSelectRef.current = false;
|
|
496
|
+
}, 0);
|
|
497
|
+
},
|
|
498
|
+
[onChange, getOptionLabel, isOptionDisabled, isApiSearch, onApiSearch, clearApiSearchTimer]
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
const handleFocus = useCallback(() => {
|
|
502
|
+
setIsFocused(true);
|
|
503
|
+
if (isDisabled) return;
|
|
504
|
+
if (mouseDownOnFieldRef.current) return;
|
|
505
|
+
setIsOpen(true);
|
|
506
|
+
}, [isDisabled]);
|
|
507
|
+
|
|
508
|
+
const handleFieldMouseDown = useCallback(() => {
|
|
509
|
+
mouseDownOnFieldRef.current = true;
|
|
510
|
+
}, []);
|
|
511
|
+
|
|
512
|
+
const handleFieldClick = useCallback(
|
|
513
|
+
(e: React.MouseEvent) => {
|
|
514
|
+
mouseDownOnFieldRef.current = false;
|
|
515
|
+
if (isDisabled) return;
|
|
516
|
+
if ((e.target as HTMLElement).closest('button')) return;
|
|
517
|
+
setIsOpen((prev) => !prev);
|
|
518
|
+
},
|
|
519
|
+
[isDisabled]
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
const handleBlurInternal = useCallback(
|
|
523
|
+
(e: React.FocusEvent<HTMLInputElement>) => {
|
|
524
|
+
setIsFocused(false);
|
|
525
|
+
setIsTyping(false);
|
|
526
|
+
// Don't clear when user just selected an option (blur can fire before parent updates value)
|
|
527
|
+
if (didSelectRef.current) {
|
|
528
|
+
didSelectRef.current = false;
|
|
529
|
+
onBlur?.(e);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
// Effective selection: use value from props, or last selected label (so click-outside doesn't clear)
|
|
533
|
+
const valueLabel = value != null ? getOptionLabel(value) : lastSelectedLabelRef.current;
|
|
534
|
+
const normalizedInput = inputValue.trim();
|
|
535
|
+
if (normalizedInput === '') {
|
|
536
|
+
if (!clearable) {
|
|
537
|
+
// clearable=false means typed deletion/backspace should not remove selection.
|
|
538
|
+
setInputValue(valueLabel);
|
|
539
|
+
onBlur?.(e);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
onChange?.(null);
|
|
543
|
+
setInputValue('');
|
|
544
|
+
lastSelectedLabelRef.current = '';
|
|
545
|
+
} else if (inputValue !== valueLabel) {
|
|
546
|
+
// User left partial/manual text: preserve the current selected option.
|
|
547
|
+
setInputValue(valueLabel);
|
|
548
|
+
}
|
|
549
|
+
onBlur?.(e);
|
|
550
|
+
},
|
|
551
|
+
[value, inputValue, getOptionLabel, onChange, onBlur, clearable]
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
const handleClear = useCallback(
|
|
555
|
+
(e: React.MouseEvent) => {
|
|
556
|
+
e.preventDefault();
|
|
557
|
+
e.stopPropagation();
|
|
558
|
+
lastSelectedLabelRef.current = '';
|
|
559
|
+
if (isApiSearch && onApiSearch) {
|
|
560
|
+
clearApiSearchTimer();
|
|
561
|
+
apiSearchInputRef.current = '';
|
|
562
|
+
onApiSearch('');
|
|
563
|
+
}
|
|
564
|
+
setInputValue('');
|
|
565
|
+
setIsTyping(false);
|
|
566
|
+
setIsOpen(false);
|
|
567
|
+
onChange?.(null);
|
|
568
|
+
},
|
|
569
|
+
[onChange, isApiSearch, onApiSearch, clearApiSearchTimer]
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
const handleArrowClick = useCallback(
|
|
573
|
+
(e: React.MouseEvent) => {
|
|
574
|
+
e.preventDefault();
|
|
575
|
+
e.stopPropagation();
|
|
576
|
+
if (!isDisabled) setIsOpen((prev) => !prev);
|
|
577
|
+
},
|
|
578
|
+
[isDisabled]
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
const handleMouseEnterWrapper = useCallback(() => setIsHovering(true), []);
|
|
582
|
+
const handleMouseLeaveWrapper = useCallback(() => setIsHovering(false), []);
|
|
583
|
+
|
|
584
|
+
const preventDefault = useCallback((e: React.MouseEvent) => {
|
|
585
|
+
e.preventDefault();
|
|
586
|
+
}, []);
|
|
587
|
+
const handleClearButtonMouseEnter = useCallback(
|
|
588
|
+
(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
589
|
+
if (!isDisabled) {
|
|
590
|
+
e.currentTarget.style.backgroundColor = scrollTokens.buttonHoverBackground;
|
|
591
|
+
e.currentTarget.style.color = scrollTokens.buttonHoverColor;
|
|
592
|
+
}
|
|
593
|
+
},
|
|
594
|
+
[isDisabled, scrollTokens.buttonHoverBackground, scrollTokens.buttonHoverColor]
|
|
595
|
+
);
|
|
596
|
+
const handleClearButtonMouseLeave = useCallback(
|
|
597
|
+
(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
598
|
+
e.currentTarget.style.backgroundColor = 'transparent';
|
|
599
|
+
e.currentTarget.style.color = scrollTokens.buttonColor;
|
|
600
|
+
},
|
|
601
|
+
[scrollTokens.buttonColor]
|
|
602
|
+
);
|
|
603
|
+
const handleArrowButtonMouseEnter = useCallback(
|
|
604
|
+
(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
605
|
+
if (!isDisabled) e.currentTarget.style.color = scrollTokens.buttonColor;
|
|
606
|
+
},
|
|
607
|
+
[isDisabled, scrollTokens.buttonColor]
|
|
608
|
+
);
|
|
609
|
+
const handleArrowButtonMouseLeave = useCallback(
|
|
610
|
+
(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
611
|
+
e.currentTarget.style.color = scrollTokens.buttonColor;
|
|
612
|
+
},
|
|
613
|
+
[scrollTokens.buttonColor]
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
useEffect(() => {
|
|
617
|
+
if (isTyping) return;
|
|
618
|
+
|
|
619
|
+
if (value != null) {
|
|
620
|
+
if (prevControlledValueRef.current !== value) {
|
|
621
|
+
const label = getOptionLabelRef.current(value);
|
|
622
|
+
setInputValue(label);
|
|
623
|
+
lastSelectedLabelRef.current = label;
|
|
624
|
+
prevControlledValueRef.current = value;
|
|
625
|
+
}
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const hadSelection = prevControlledValueRef.current != null;
|
|
630
|
+
prevControlledValueRef.current = null;
|
|
631
|
+
|
|
632
|
+
if (hadSelection) {
|
|
633
|
+
setInputValue('');
|
|
634
|
+
lastSelectedLabelRef.current = '';
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (isApiSearch && onApiSearch) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
setInputValue('');
|
|
643
|
+
lastSelectedLabelRef.current = '';
|
|
644
|
+
}, [value, isApiSearch, onApiSearch, isTyping]);
|
|
645
|
+
|
|
646
|
+
useEffect(() => {
|
|
647
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
648
|
+
if (
|
|
649
|
+
!containerRef.current?.contains(e.target as Node) &&
|
|
650
|
+
!listRef.current?.contains(e.target as Node)
|
|
651
|
+
) {
|
|
652
|
+
setIsOpen(false);
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
const handleMouseUp = () => {
|
|
656
|
+
mouseDownOnFieldRef.current = false;
|
|
657
|
+
};
|
|
658
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
659
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
660
|
+
return () => {
|
|
661
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
662
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
663
|
+
};
|
|
664
|
+
}, []);
|
|
665
|
+
|
|
666
|
+
useLayoutEffect(() => {
|
|
667
|
+
if (!isOpen) {
|
|
668
|
+
setIsTyping(false);
|
|
669
|
+
setListboxPosition(null);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
let active = true;
|
|
674
|
+
updateListboxPosition();
|
|
675
|
+
|
|
676
|
+
// Re-measure after mount (helps for the "up" case and shorter-than-max lists).
|
|
677
|
+
// Use two rAFs to ensure the portal has rendered and layout has settled.
|
|
678
|
+
const raf1 = window.requestAnimationFrame(() => {
|
|
679
|
+
if (!active) return;
|
|
680
|
+
window.requestAnimationFrame(() => {
|
|
681
|
+
if (!active) return;
|
|
682
|
+
updateListboxPosition();
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
return () => {
|
|
687
|
+
active = false;
|
|
688
|
+
window.cancelAnimationFrame(raf1);
|
|
689
|
+
};
|
|
690
|
+
}, [isOpen, updateListboxPosition, filteredOptions.length, loading, noDataFound]);
|
|
691
|
+
|
|
692
|
+
useEffect(() => {
|
|
693
|
+
if (!isOpen) return;
|
|
694
|
+
if (value == null) return;
|
|
695
|
+
const raf1 = window.requestAnimationFrame(() => {
|
|
696
|
+
window.requestAnimationFrame(() => {
|
|
697
|
+
if (!listRef.current) return;
|
|
698
|
+
const selectedItem = listRef.current.querySelector('[aria-selected="true"]');
|
|
699
|
+
if (selectedItem) {
|
|
700
|
+
(selectedItem as HTMLElement).scrollIntoView({
|
|
701
|
+
block: 'nearest',
|
|
702
|
+
behavior: 'auto',
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
return () => {
|
|
709
|
+
window.cancelAnimationFrame(raf1);
|
|
710
|
+
};
|
|
711
|
+
}, [isOpen, value]);
|
|
712
|
+
|
|
713
|
+
/** Re-run when list height changes (async data, skeleton row, 1 vs many rows) without re-opening. */
|
|
714
|
+
useLayoutEffect(() => {
|
|
715
|
+
if (!isOpen || !hasListboxPosition) return;
|
|
716
|
+
const el = listRef.current;
|
|
717
|
+
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
718
|
+
const ro = new ResizeObserver(() => {
|
|
719
|
+
scheduleListboxPositionUpdate();
|
|
720
|
+
});
|
|
721
|
+
ro.observe(el);
|
|
722
|
+
return () => ro.disconnect();
|
|
723
|
+
}, [
|
|
724
|
+
isOpen,
|
|
725
|
+
hasListboxPosition,
|
|
726
|
+
scheduleListboxPositionUpdate,
|
|
727
|
+
filteredOptions.length,
|
|
728
|
+
loading,
|
|
729
|
+
noDataFound,
|
|
730
|
+
]);
|
|
731
|
+
|
|
732
|
+
useEffect(() => {
|
|
733
|
+
if (!isOpen) {
|
|
734
|
+
if (isApiSearch && onApiSearch) {
|
|
735
|
+
clearApiSearchTimer();
|
|
736
|
+
if (apiSearchInputRef.current !== '') {
|
|
737
|
+
apiSearchInputRef.current = '';
|
|
738
|
+
onApiSearch('');
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}, [isOpen, isApiSearch, onApiSearch, clearApiSearchTimer]);
|
|
743
|
+
|
|
744
|
+
useEffect(() => {
|
|
745
|
+
if (!isOpen) return;
|
|
746
|
+
|
|
747
|
+
const handleWindowChange = () => {
|
|
748
|
+
scheduleListboxPositionUpdate();
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
window.addEventListener('resize', handleWindowChange);
|
|
752
|
+
window.addEventListener('scroll', handleWindowChange, true);
|
|
753
|
+
|
|
754
|
+
return () => {
|
|
755
|
+
window.removeEventListener('resize', handleWindowChange);
|
|
756
|
+
window.removeEventListener('scroll', handleWindowChange, true);
|
|
757
|
+
};
|
|
758
|
+
}, [isOpen, scheduleListboxPositionUpdate]);
|
|
759
|
+
|
|
760
|
+
useEffect(() => {
|
|
761
|
+
return () => {
|
|
762
|
+
if (listboxPositionRafRef.current != null) {
|
|
763
|
+
cancelAnimationFrame(listboxPositionRafRef.current);
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
}, []);
|
|
767
|
+
|
|
768
|
+
useEffect(() => {
|
|
769
|
+
if (openChangeSkipMountRef.current) {
|
|
770
|
+
openChangeSkipMountRef.current = false;
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
onOpenChangeRef.current?.(isOpen);
|
|
774
|
+
}, [isOpen]);
|
|
775
|
+
|
|
776
|
+
const hasValue = value != null;
|
|
777
|
+
const showClearButton =
|
|
778
|
+
clearable && (isOpen || (isHovering && !isDisabled)) && (hasValue || inputValue !== '');
|
|
779
|
+
// Reserve space for the right-side controls so the field doesn't "shift" when the clear (X) appears.
|
|
780
|
+
const inputPaddingRight = 56;
|
|
781
|
+
const inputHeight =
|
|
782
|
+
size === 'sm' ? parseInt(scrollTokens.inputHeightSm, 10)
|
|
783
|
+
: size === 'lg' ? parseInt(scrollTokens.inputHeightLg, 10)
|
|
784
|
+
: parseInt(scrollTokens.inputHeightMd, 10);
|
|
785
|
+
const inputPadding =
|
|
786
|
+
size === 'sm' ? scrollTokens.inputPaddingSm
|
|
787
|
+
: size === 'lg' ? scrollTokens.inputPaddingLg
|
|
788
|
+
: scrollTokens.inputPaddingMd;
|
|
789
|
+
const inputFontSize =
|
|
790
|
+
size === 'sm' ? scrollTokens.inputFontSizeSm
|
|
791
|
+
: size === 'lg' ? scrollTokens.inputFontSizeLg
|
|
792
|
+
: scrollTokens.inputFontSizeMd;
|
|
793
|
+
|
|
794
|
+
const containerStyle = useMemo(
|
|
795
|
+
() => ({
|
|
796
|
+
width: '100%',
|
|
797
|
+
position: 'relative' as const,
|
|
798
|
+
fontFamily: 'sans-serif',
|
|
799
|
+
...style,
|
|
800
|
+
}),
|
|
801
|
+
[style]
|
|
802
|
+
);
|
|
803
|
+
|
|
804
|
+
const inputStyle = useMemo(
|
|
805
|
+
() => ({
|
|
806
|
+
width: '100%',
|
|
807
|
+
padding: inputPadding,
|
|
808
|
+
paddingRight: inputPaddingRight,
|
|
809
|
+
border:
|
|
810
|
+
isInvalid ? scrollTokens.inputBorderError
|
|
811
|
+
: isFocused ? scrollTokens.inputBorderActive
|
|
812
|
+
: '1px solid #c7ced6',
|
|
813
|
+
borderRadius: scrollTokens.inputBorderRadius,
|
|
814
|
+
fontSize: inputFontSize,
|
|
815
|
+
minHeight: inputHeight,
|
|
816
|
+
boxSizing: 'border-box' as const,
|
|
817
|
+
backgroundColor:
|
|
818
|
+
color === 'white' ? scrollTokens.inputBackgroundWhite : scrollTokens.inputBackgroundWhite,
|
|
819
|
+
outline: 'none',
|
|
820
|
+
boxShadow: 'none',
|
|
821
|
+
color: isDisabled ? scrollTokens.inputColor : scrollTokens.inputColor,
|
|
822
|
+
}),
|
|
823
|
+
[
|
|
824
|
+
inputPaddingRight,
|
|
825
|
+
inputPadding,
|
|
826
|
+
inputFontSize,
|
|
827
|
+
isInvalid,
|
|
828
|
+
isFocused,
|
|
829
|
+
inputHeight,
|
|
830
|
+
isDisabled,
|
|
831
|
+
scrollTokens,
|
|
832
|
+
color,
|
|
833
|
+
]
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
const listboxStyle = useMemo(
|
|
837
|
+
() => ({
|
|
838
|
+
position: 'absolute' as const,
|
|
839
|
+
top: 0,
|
|
840
|
+
left: 0,
|
|
841
|
+
right: 0,
|
|
842
|
+
// background: scrollTokens.listboxBackground,
|
|
843
|
+
background: '#ffffff',
|
|
844
|
+
// border: scrollTokens.listboxBorder,
|
|
845
|
+
border: '1px solid #c7ced6',
|
|
846
|
+
borderRadius: scrollTokens.listboxBorderRadius,
|
|
847
|
+
marginTop: 0,
|
|
848
|
+
padding: 0,
|
|
849
|
+
listStyle: 'none' as const,
|
|
850
|
+
maxHeight: LIST_MAX_HEIGHT,
|
|
851
|
+
overflowY: 'auto' as const,
|
|
852
|
+
boxShadow: '0 4px 8px rgba(0,0,0,0.1)',
|
|
853
|
+
zIndex: 1000002,
|
|
854
|
+
}),
|
|
855
|
+
[scrollTokens]
|
|
856
|
+
);
|
|
857
|
+
|
|
858
|
+
return (
|
|
859
|
+
<div ref={containerRef} style={containerStyle}>
|
|
860
|
+
<style>
|
|
861
|
+
{`.select-scroll-input::placeholder {
|
|
862
|
+
color: ${scrollTokens.placeholderColor};
|
|
863
|
+
}
|
|
864
|
+
.select-scroll-input:disabled::placeholder {
|
|
865
|
+
color: #c7ced6;
|
|
866
|
+
}
|
|
867
|
+
.select-scroll-listbox {
|
|
868
|
+
scrollbar-width: thin;
|
|
869
|
+
scrollbar-color: #C8D0D5 transparent;
|
|
870
|
+
}
|
|
871
|
+
.select-scroll-listbox::-webkit-scrollbar {
|
|
872
|
+
width: 8px;
|
|
873
|
+
}
|
|
874
|
+
.select-scroll-listbox::-webkit-scrollbar-track {
|
|
875
|
+
background: transparent;
|
|
876
|
+
border-radius: 4px;
|
|
877
|
+
}
|
|
878
|
+
.select-scroll-listbox::-webkit-scrollbar-thumb {
|
|
879
|
+
background-color: #C8D0D5;
|
|
880
|
+
border-radius: 4px;
|
|
881
|
+
}
|
|
882
|
+
.select-scroll-listbox::-webkit-scrollbar-thumb:hover {
|
|
883
|
+
background-color: #B0B8BD;
|
|
884
|
+
}
|
|
885
|
+
.select-scroll-listbox::-webkit-scrollbar-button {
|
|
886
|
+
display: none !important;
|
|
887
|
+
width: 0 !important;
|
|
888
|
+
height: 0 !important;
|
|
889
|
+
}
|
|
890
|
+
.select-scroll-listbox::-webkit-scrollbar-button:start:decrement,
|
|
891
|
+
.select-scroll-listbox::-webkit-scrollbar-button:end:increment {
|
|
892
|
+
display: none !important;
|
|
893
|
+
}
|
|
894
|
+
.select-scroll-listbox::-webkit-scrollbar-corner {
|
|
895
|
+
background: transparent;
|
|
896
|
+
}`}
|
|
897
|
+
</style>
|
|
898
|
+
{label && (
|
|
899
|
+
<label
|
|
900
|
+
onClick={(e) => {
|
|
901
|
+
if (isDisabled) return;
|
|
902
|
+
if (isOpen) {
|
|
903
|
+
setIsOpen(false);
|
|
904
|
+
} else {
|
|
905
|
+
e.preventDefault();
|
|
906
|
+
}
|
|
907
|
+
}}
|
|
908
|
+
style={{
|
|
909
|
+
display: 'block',
|
|
910
|
+
marginBottom: parseInt(scrollTokens.labelMarginBottom, 10),
|
|
911
|
+
fontSize: scrollTokens.labelFontSize,
|
|
912
|
+
fontWeight: scrollTokens.labelFontWeight,
|
|
913
|
+
color: isDisabled ? scrollTokens.labelColor : scrollTokens.labelColor,
|
|
914
|
+
cursor: 'default',
|
|
915
|
+
}}
|
|
916
|
+
>
|
|
917
|
+
{label}
|
|
918
|
+
{required && (
|
|
919
|
+
<span style={{ color: scrollTokens.labelRequiredColor, marginRight: 4 }}>*</span>
|
|
920
|
+
)}
|
|
921
|
+
</label>
|
|
922
|
+
)}
|
|
923
|
+
<div
|
|
924
|
+
style={{ position: 'relative' }}
|
|
925
|
+
ref={fieldAnchorRef}
|
|
926
|
+
onMouseEnter={handleMouseEnterWrapper}
|
|
927
|
+
onMouseLeave={handleMouseLeaveWrapper}
|
|
928
|
+
>
|
|
929
|
+
<input
|
|
930
|
+
id={id}
|
|
931
|
+
type="text"
|
|
932
|
+
autoComplete="off"
|
|
933
|
+
placeholder={placeholder}
|
|
934
|
+
value={inputValue}
|
|
935
|
+
onChange={handleInputChange}
|
|
936
|
+
onFocus={handleFocus}
|
|
937
|
+
onMouseDown={handleFieldMouseDown}
|
|
938
|
+
onClick={handleFieldClick}
|
|
939
|
+
onBlur={handleBlurInternal}
|
|
940
|
+
disabled={isDisabled}
|
|
941
|
+
aria-invalid={isInvalid}
|
|
942
|
+
aria-required={required}
|
|
943
|
+
style={inputStyle}
|
|
944
|
+
className="select-scroll-input"
|
|
945
|
+
/>
|
|
946
|
+
<div
|
|
947
|
+
style={{
|
|
948
|
+
position: 'absolute',
|
|
949
|
+
right: 8,
|
|
950
|
+
top: '50%',
|
|
951
|
+
transform: 'translateY(-50%)',
|
|
952
|
+
zIndex: 1,
|
|
953
|
+
display: 'flex',
|
|
954
|
+
alignItems: 'center',
|
|
955
|
+
gap: 2,
|
|
956
|
+
}}
|
|
957
|
+
>
|
|
958
|
+
{showClearButton && (
|
|
959
|
+
<button
|
|
960
|
+
type="button"
|
|
961
|
+
onClick={handleClear}
|
|
962
|
+
onMouseDown={preventDefault}
|
|
963
|
+
disabled={isDisabled}
|
|
964
|
+
aria-label="Clear selection"
|
|
965
|
+
style={{
|
|
966
|
+
display: 'flex',
|
|
967
|
+
alignItems: 'center',
|
|
968
|
+
justifyContent: 'center',
|
|
969
|
+
width: 24,
|
|
970
|
+
height: 24,
|
|
971
|
+
padding: 0,
|
|
972
|
+
border: 'none',
|
|
973
|
+
background: 'transparent',
|
|
974
|
+
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
|
975
|
+
color: scrollTokens.buttonColor,
|
|
976
|
+
borderRadius: '50%',
|
|
977
|
+
}}
|
|
978
|
+
onMouseEnter={handleClearButtonMouseEnter}
|
|
979
|
+
onMouseLeave={handleClearButtonMouseLeave}
|
|
980
|
+
>
|
|
981
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
|
982
|
+
<path d="M14 1.41L12.59 0L7 5.59L1.41 0L0 1.41L5.59 7L0 12.59L1.41 14L7 8.41L12.59 14L14 12.59L8.41 7L14 1.41z" />
|
|
983
|
+
</svg>
|
|
984
|
+
</button>
|
|
985
|
+
)}
|
|
986
|
+
<button
|
|
987
|
+
type="button"
|
|
988
|
+
onClick={handleArrowClick}
|
|
989
|
+
onMouseDown={preventDefault}
|
|
990
|
+
disabled={isDisabled}
|
|
991
|
+
aria-label={isOpen ? 'Close dropdown' : 'Open dropdown'}
|
|
992
|
+
style={{
|
|
993
|
+
display: 'flex',
|
|
994
|
+
alignItems: 'center',
|
|
995
|
+
justifyContent: 'center',
|
|
996
|
+
width: 24,
|
|
997
|
+
height: 24,
|
|
998
|
+
padding: 0,
|
|
999
|
+
border: 'none',
|
|
1000
|
+
background: 'transparent',
|
|
1001
|
+
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
|
1002
|
+
color: isDisabled ? scrollTokens.buttonColor : scrollTokens.buttonColor,
|
|
1003
|
+
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
|
1004
|
+
transition: 'transform 0.2s',
|
|
1005
|
+
}}
|
|
1006
|
+
onMouseEnter={handleArrowButtonMouseEnter}
|
|
1007
|
+
onMouseLeave={handleArrowButtonMouseLeave}
|
|
1008
|
+
>
|
|
1009
|
+
<svg
|
|
1010
|
+
width="16"
|
|
1011
|
+
height="16"
|
|
1012
|
+
viewBox="0 0 16 16"
|
|
1013
|
+
fill="none"
|
|
1014
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
1015
|
+
>
|
|
1016
|
+
<path
|
|
1017
|
+
d="M4 6L8 10L12 6"
|
|
1018
|
+
stroke="currentColor"
|
|
1019
|
+
strokeWidth="2.5"
|
|
1020
|
+
strokeLinecap="round"
|
|
1021
|
+
strokeLinejoin="round"
|
|
1022
|
+
/>
|
|
1023
|
+
</svg>
|
|
1024
|
+
</button>
|
|
1025
|
+
</div>
|
|
1026
|
+
</div>
|
|
1027
|
+
{(isInvalid && error) || (!isInvalid && helperText) ?
|
|
1028
|
+
<div
|
|
1029
|
+
style={{
|
|
1030
|
+
fontSize: scrollTokens.helperFontSize,
|
|
1031
|
+
marginTop: 4,
|
|
1032
|
+
color:
|
|
1033
|
+
isInvalid ? scrollTokens.helperErrorColor
|
|
1034
|
+
: isDisabled ? scrollTokens.inputColor
|
|
1035
|
+
: scrollTokens.helperColor,
|
|
1036
|
+
}}
|
|
1037
|
+
role={isInvalid ? 'alert' : undefined}
|
|
1038
|
+
>
|
|
1039
|
+
{isInvalid && error ? error : helperText}
|
|
1040
|
+
</div>
|
|
1041
|
+
: null}
|
|
1042
|
+
{isOpen &&
|
|
1043
|
+
listboxPosition &&
|
|
1044
|
+
createPortal(
|
|
1045
|
+
<ul
|
|
1046
|
+
ref={setListRef}
|
|
1047
|
+
onScroll={handleListScroll}
|
|
1048
|
+
role="listbox"
|
|
1049
|
+
aria-label={label ?? placeholder}
|
|
1050
|
+
style={{
|
|
1051
|
+
...listboxStyle,
|
|
1052
|
+
position: 'fixed',
|
|
1053
|
+
top: listboxPosition.top,
|
|
1054
|
+
left: listboxPosition.left,
|
|
1055
|
+
width: listboxPosition.width,
|
|
1056
|
+
right: 'unset',
|
|
1057
|
+
}}
|
|
1058
|
+
className="select-scroll-listbox"
|
|
1059
|
+
>
|
|
1060
|
+
{noDataFound && (
|
|
1061
|
+
<li
|
|
1062
|
+
style={{
|
|
1063
|
+
padding: scrollTokens.optionPadding,
|
|
1064
|
+
textAlign: 'center',
|
|
1065
|
+
color: scrollTokens.optionColor,
|
|
1066
|
+
fontSize: scrollTokens.optionFontSize,
|
|
1067
|
+
}}
|
|
1068
|
+
>
|
|
1069
|
+
{noOptionsText}
|
|
1070
|
+
</li>
|
|
1071
|
+
)}
|
|
1072
|
+
{!noDataFound &&
|
|
1073
|
+
filteredOptions.map((option, index) => {
|
|
1074
|
+
const disabled = isOptionDisabled?.(option) ?? false;
|
|
1075
|
+
const optionLabel = getOptionLabel(option);
|
|
1076
|
+
const isSelected =
|
|
1077
|
+
value !== undefined &&
|
|
1078
|
+
value !== null &&
|
|
1079
|
+
(isOptionEqual ?
|
|
1080
|
+
isOptionEqual(option, value)
|
|
1081
|
+
: areSelectOptionsEqual(option, value, getOptionLabel));
|
|
1082
|
+
const optionValueKey = getOptionValueKey(option as SelectScrollOption);
|
|
1083
|
+
const key = optionValueKey ?? `${optionLabel}-${index}`;
|
|
1084
|
+
return (
|
|
1085
|
+
<OptionItem
|
|
1086
|
+
key={key}
|
|
1087
|
+
option={option}
|
|
1088
|
+
optionLabel={optionLabel}
|
|
1089
|
+
disabled={disabled}
|
|
1090
|
+
selected={isSelected}
|
|
1091
|
+
index={index}
|
|
1092
|
+
onSelect={handleSelect}
|
|
1093
|
+
renderOption={renderOption}
|
|
1094
|
+
customRenderOptionView={customRenderOptionView}
|
|
1095
|
+
scrollTokens={scrollTokens}
|
|
1096
|
+
/>
|
|
1097
|
+
);
|
|
1098
|
+
})}
|
|
1099
|
+
{loading && !noDataFound && (
|
|
1100
|
+
<li
|
|
1101
|
+
style={{
|
|
1102
|
+
padding: scrollTokens.optionPadding,
|
|
1103
|
+
textAlign: 'center',
|
|
1104
|
+
color: scrollTokens.optionColor,
|
|
1105
|
+
fontSize: scrollTokens.optionFontSize,
|
|
1106
|
+
}}
|
|
1107
|
+
>
|
|
1108
|
+
{loadingText}
|
|
1109
|
+
</li>
|
|
1110
|
+
)}
|
|
1111
|
+
</ul>,
|
|
1112
|
+
document.body
|
|
1113
|
+
)}
|
|
1114
|
+
</div>
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const SelectScroll = React.memo(SelectScrollInner) as typeof SelectScrollInner;
|
|
1119
|
+
export default SelectScroll;
|