@crab-dev/rc-select 0.0.3 → 0.1.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/CHANGELOG.md +59 -0
- package/README.md +9 -14
- package/cjs/_wake/src/hooks/useKeyboardNavigation.cjs +40 -0
- package/cjs/_wake/src/select.cjs +155 -0
- package/cjs/_wake/src/selectInput.cjs +205 -0
- package/cjs/_wake/src/selectInput.styles.cjs +69 -0
- package/cjs/_wake/src/selectOverlay.cjs +98 -0
- package/cjs/_wake/src/selectOverlay.styles.cjs +68 -0
- package/cjs/_wake/src/token.cjs +11 -0
- package/cjs/index.cjs +11 -1
- package/css/index.css +5 -42
- package/declarations/_wake/src/select.d.ts +44 -0
- package/declarations/_wake/src/token.d.ts +3 -0
- package/declarations/_wake/src/types.d.ts +79 -0
- package/declarations/index.d.ts +4 -79
- package/esm/_wake/src/hooks/useKeyboardNavigation.mjs +36 -0
- package/esm/_wake/src/select.mjs +151 -0
- package/esm/_wake/src/selectInput.mjs +201 -0
- package/esm/_wake/src/selectInput.styles.mjs +20 -0
- package/esm/_wake/src/selectOverlay.mjs +94 -0
- package/esm/_wake/src/selectOverlay.styles.mjs +19 -0
- package/esm/_wake/src/token.mjs +4 -0
- package/esm/index.mjs +4 -1
- package/package.json +47 -46
package/declarations/index.d.ts
CHANGED
|
@@ -1,79 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* - `label`: 渲染时显示的内容(可以是字符串或 React 节点)
|
|
6
|
-
* - `value`: 选项的唯一标识(字符串)
|
|
7
|
-
* - `disabled`: 可选,是否禁用该选项
|
|
8
|
-
*/
|
|
9
|
-
interface SelectOption {
|
|
10
|
-
label: ReactNode;
|
|
11
|
-
value: string;
|
|
12
|
-
disabled?: boolean;
|
|
13
|
-
}
|
|
14
|
-
/**
|
|
15
|
-
* 选项分组
|
|
16
|
-
* - `label`: 分组标题(用于在下拉列表中显示)
|
|
17
|
-
* - `options`: 该分组包含的多个 `SelectOption`
|
|
18
|
-
*/
|
|
19
|
-
interface SelectOptionGroup {
|
|
20
|
-
label: ReactNode;
|
|
21
|
-
options: SelectOption[];
|
|
22
|
-
}
|
|
23
|
-
type SelectOptionOrGroup = SelectOption | SelectOptionGroup;
|
|
24
|
-
/**
|
|
25
|
-
* 基础 Props,适用于单选和多选。继承自 div 的 HTML 属性,但排除了
|
|
26
|
-
* `onChange` 和 `defaultValue`,因为它们在单选/多选中有不同签名。
|
|
27
|
-
*/
|
|
28
|
-
interface BaseSelectProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue"> {
|
|
29
|
-
ref?: Ref<HTMLDivElement>;
|
|
30
|
-
options: SelectOptionOrGroup[];
|
|
31
|
-
placeholder?: string;
|
|
32
|
-
disabled?: boolean;
|
|
33
|
-
searchable?: boolean;
|
|
34
|
-
size?: "large" | "middle" | "small";
|
|
35
|
-
status?: "error" | "warning";
|
|
36
|
-
allowClear?: boolean;
|
|
37
|
-
loading?: boolean;
|
|
38
|
-
maxTagCount?: number;
|
|
39
|
-
autoFocus?: boolean;
|
|
40
|
-
notFoundContent?: ReactNode;
|
|
41
|
-
popupClassName?: string;
|
|
42
|
-
popupMatchSelectWidth?: boolean;
|
|
43
|
-
optionRender?: (option: SelectOption, info: {
|
|
44
|
-
selected: boolean;
|
|
45
|
-
}) => ReactNode;
|
|
46
|
-
tagRender?: (option: SelectOption, onClose: () => void) => ReactNode;
|
|
47
|
-
dropdownRender?: (menu: ReactNode) => ReactNode;
|
|
48
|
-
onOpenChange?: (open: boolean) => void;
|
|
49
|
-
onFocus?: () => void;
|
|
50
|
-
onBlur?: () => void;
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* 单选模式 Props
|
|
54
|
-
* - `multiple` 可选或 false
|
|
55
|
-
* - `value` / `defaultValue` 为单个字符串或 undefined
|
|
56
|
-
*/
|
|
57
|
-
interface SingleSelectProps extends BaseSelectProps {
|
|
58
|
-
multiple?: false;
|
|
59
|
-
value?: string;
|
|
60
|
-
defaultValue?: string;
|
|
61
|
-
onChange?: (value: string | undefined, option: SelectOption | undefined) => void;
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* 多选模式 Props
|
|
65
|
-
* - `multiple` 必须为 true
|
|
66
|
-
* - `value` / `defaultValue` 为字符串数组
|
|
67
|
-
*/
|
|
68
|
-
interface MultiSelectProps extends BaseSelectProps {
|
|
69
|
-
multiple: true;
|
|
70
|
-
value?: string[];
|
|
71
|
-
defaultValue?: string[];
|
|
72
|
-
onChange?: (value: string[], options: SelectOption[]) => void;
|
|
73
|
-
}
|
|
74
|
-
type SelectProps = SingleSelectProps | MultiSelectProps;
|
|
75
|
-
|
|
76
|
-
declare const Select: FC<SelectProps>;
|
|
77
|
-
|
|
78
|
-
export { Select as default };
|
|
79
|
-
export type { SelectOption, SelectOptionGroup, SelectOptionOrGroup, SelectProps };
|
|
1
|
+
import Select from './_wake/src/select.js';
|
|
2
|
+
export type { SelectOption, SelectOptionGroup, SelectOptionOrGroup, SelectProps, SelectChangeDetails } from './_wake/src/types.js';
|
|
3
|
+
export default Select;
|
|
4
|
+
export { vars as TokenVars } from './_wake/src/token.js';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
;
|
|
3
|
+
;
|
|
4
|
+
const useKeyboardNavigation = ({ filteredOptions, open }) => {
|
|
5
|
+
const [highlightIndex, setHighlightIndex] = useState(-1);
|
|
6
|
+
const findNextEnabledIndex = (startIndex, direction) => {
|
|
7
|
+
const len = filteredOptions.length;
|
|
8
|
+
if (len === 0) {
|
|
9
|
+
return -1;
|
|
10
|
+
}
|
|
11
|
+
let index = startIndex;
|
|
12
|
+
for (let i = 0; i < len; i += 1) {
|
|
13
|
+
index = ((index + direction) % len + len) % len;
|
|
14
|
+
const opt = filteredOptions[index];
|
|
15
|
+
if (!opt.disabled && !opt.isGroupLabel) {
|
|
16
|
+
return index;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return -1;
|
|
20
|
+
};
|
|
21
|
+
const moveHighlight = (direction) => {
|
|
22
|
+
if (!open) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
setHighlightIndex((prev) => {
|
|
26
|
+
const start = prev === -1 ? direction === 1 ? -1 : 0 : prev;
|
|
27
|
+
return findNextEnabledIndex(start, direction);
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
const resetHighlight = () => {
|
|
31
|
+
setHighlightIndex(-1);
|
|
32
|
+
};
|
|
33
|
+
const highlightedOption = highlightIndex >= 0 && highlightIndex < filteredOptions.length ? filteredOptions[highlightIndex] : undefined;
|
|
34
|
+
return { highlightIndex, highlightedOption, moveHighlight, resetHighlight, setHighlightIndex };
|
|
35
|
+
};
|
|
36
|
+
export default useKeyboardNavigation;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useComponentSize, useLocale } from "@crab-dev/rc-config-provider";
|
|
3
|
+
import RcDropdownContainer from "@crab-dev/rc-dropdown-container";
|
|
4
|
+
import { useDropdownPositionReference } from "@crab-dev/rc-dropdown-container/internal";
|
|
5
|
+
import semanticToken from "@crab-dev/rc-token-semantic";
|
|
6
|
+
import { useId, useRef, useState } from "react";
|
|
7
|
+
;
|
|
8
|
+
import { useControllableValue } from "@crab-dev/rc-hooks";
|
|
9
|
+
import useKeyboardNavigation from "./hooks/useKeyboardNavigation.mjs";
|
|
10
|
+
import SelectInput from "./selectInput.mjs";
|
|
11
|
+
import SelectOverlay from "./selectOverlay.mjs";
|
|
12
|
+
import token from "./token.mjs";
|
|
13
|
+
;
|
|
14
|
+
const isGroup = (item) => "options" in item && Array.isArray(item.options);
|
|
15
|
+
const asArray = (value) => {
|
|
16
|
+
if (value == null) {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
return Array.isArray(value) ? value : [value];
|
|
20
|
+
};
|
|
21
|
+
const flattenOptions = (options) => {
|
|
22
|
+
const result = [];
|
|
23
|
+
options.forEach((item, index) => {
|
|
24
|
+
if (isGroup(item)) {
|
|
25
|
+
result.push({ label: item.label, value: `__group_${index}`, isGroupLabel: true });
|
|
26
|
+
for (const opt of item.options) {
|
|
27
|
+
result.push(opt);
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
result.push(item);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
const flattenPlainOptions = (options) => {
|
|
36
|
+
const result = [];
|
|
37
|
+
for (const item of options) {
|
|
38
|
+
if (isGroup(item)) {
|
|
39
|
+
for (const opt of item.options) {
|
|
40
|
+
result.push(opt);
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
result.push(item);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
};
|
|
48
|
+
const filterOptions = (flatOptions, searchable, searchText) => {
|
|
49
|
+
if (!searchable || searchText.trim() === "") {
|
|
50
|
+
return flatOptions;
|
|
51
|
+
}
|
|
52
|
+
const keyword = searchText.trim().toLowerCase();
|
|
53
|
+
return flatOptions.filter((opt) => {
|
|
54
|
+
if (opt.isGroupLabel) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
if (typeof opt.label === "string") {
|
|
58
|
+
return opt.label.toLowerCase().includes(keyword);
|
|
59
|
+
}
|
|
60
|
+
return opt.value.toLowerCase().includes(keyword);
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
const firstEnabledIndex = (flatOptions) => flatOptions.findIndex((opt) => !opt.disabled && !opt.isGroupLabel);
|
|
64
|
+
const fieldStyle = "rc-select-0f3ecf945b98";
|
|
65
|
+
const supportingStyle = "rc-select-3bd2a27273ad";
|
|
66
|
+
function SelectField({ children }) {
|
|
67
|
+
const setPositionReference = useDropdownPositionReference();
|
|
68
|
+
return _jsx("div", { className: fieldStyle, ref: setPositionReference, children: children });
|
|
69
|
+
}
|
|
70
|
+
function Select({ ref, id, options, placeholder, disabled = false, searchable = false, multiple = false, size: sizeProp, status, appearance = "outlined", label, supportingText, errorText, required, allowClear = false, loading = false, maxTagCount, autoFocus = false, notFoundContent, popupClassName, popupMatchSelectWidth = true, optionRender, tagRender, dropdownRender, value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, onFocus, onBlur, className, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, ...restProps }) {
|
|
71
|
+
const size = useComponentSize(sizeProp);
|
|
72
|
+
const { messages } = useLocale();
|
|
73
|
+
const listboxId = useId();
|
|
74
|
+
const generatedId = useId();
|
|
75
|
+
const inputId = id ?? generatedId;
|
|
76
|
+
const labelId = `${generatedId}-label`;
|
|
77
|
+
const descriptionId = `${generatedId}-description`;
|
|
78
|
+
const fieldStatus = errorText ? "error" : status;
|
|
79
|
+
const description = errorText || supportingText;
|
|
80
|
+
const [searchText, setSearchText] = useState("");
|
|
81
|
+
const [selectedValues, setSelectedValues] = useControllableValue({ value: value !== undefined ? asArray(value) : undefined, defaultValue: asArray(defaultValue) });
|
|
82
|
+
const [triggerWidth, setTriggerWidth] = useState(0);
|
|
83
|
+
const controlRef = useRef(null);
|
|
84
|
+
const allPlainOptions = flattenPlainOptions(options);
|
|
85
|
+
const selectedOptions = allPlainOptions.filter((opt) => selectedValues.includes(opt.value));
|
|
86
|
+
const flatOptions = flattenOptions(options);
|
|
87
|
+
const filteredOptions = filterOptions(flatOptions, searchable, searchText);
|
|
88
|
+
const { highlightIndex, highlightedOption, moveHighlight, resetHighlight, setHighlightIndex } = useKeyboardNavigation({ filteredOptions, open: true });
|
|
89
|
+
const syncOpenState = (nextOpen) => {
|
|
90
|
+
if (nextOpen) {
|
|
91
|
+
if (!multiple && selectedValues.length > 0) {
|
|
92
|
+
const selectedIndex = filteredOptions.findIndex((opt) => opt.value === selectedValues[0]);
|
|
93
|
+
setHighlightIndex(selectedIndex);
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
setSearchText("");
|
|
97
|
+
resetHighlight();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const emitOpenChange = (nextOpen, details) => {
|
|
101
|
+
onOpenChange?.(nextOpen, details);
|
|
102
|
+
};
|
|
103
|
+
const openProps = open === undefined ? { defaultOpen } : { open };
|
|
104
|
+
const updateValue = (nextValues, reason, event) => {
|
|
105
|
+
if (disabled) return;
|
|
106
|
+
if (nextValues.length === selectedValues.length && nextValues.every((item, index) => item === selectedValues[index])) return;
|
|
107
|
+
setSelectedValues(nextValues);
|
|
108
|
+
if (multiple) {
|
|
109
|
+
const nextOptions = allPlainOptions.filter((opt) => nextValues.includes(opt.value));
|
|
110
|
+
onValueChange?.(nextValues, { reason, options: nextOptions, event });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const nextValue = nextValues[0] ?? null;
|
|
114
|
+
const nextOption = allPlainOptions.find((opt) => opt.value === nextValue);
|
|
115
|
+
onValueChange?.(nextValue, { reason, option: nextOption, options: nextOption ? [nextOption] : [], event });
|
|
116
|
+
};
|
|
117
|
+
const handleOptionSelect = (option, event) => {
|
|
118
|
+
if (disabled || option.disabled || option.isGroupLabel) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (multiple) {
|
|
122
|
+
const exists = selectedValues.includes(option.value);
|
|
123
|
+
const nextValues = exists ? selectedValues.filter((v) => v !== option.value) : [...selectedValues, option.value];
|
|
124
|
+
updateValue(nextValues, exists ? "deselect" : "select", event);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
controlRef.current?.focus();
|
|
128
|
+
updateValue([option.value], "select", event);
|
|
129
|
+
};
|
|
130
|
+
const handleClear = (event) => {
|
|
131
|
+
if (disabled) return;
|
|
132
|
+
updateValue([], "clear", event);
|
|
133
|
+
setSearchText("");
|
|
134
|
+
};
|
|
135
|
+
const handleRemoveTag = (val, event) => {
|
|
136
|
+
updateValue(selectedValues.filter((v) => v !== val), "remove", event);
|
|
137
|
+
};
|
|
138
|
+
const handleTriggerWidthChange = (width) => {
|
|
139
|
+
setTriggerWidth(width);
|
|
140
|
+
};
|
|
141
|
+
const handleSearchTextChange = (text) => {
|
|
142
|
+
if (disabled) return;
|
|
143
|
+
setSearchText(text);
|
|
144
|
+
const nextFiltered = filterOptions(flatOptions, searchable, text);
|
|
145
|
+
setHighlightIndex(firstEnabledIndex(nextFiltered));
|
|
146
|
+
};
|
|
147
|
+
const floatingStyle = popupMatchSelectWidth ? { width: triggerWidth } : { minWidth: triggerWidth };
|
|
148
|
+
return _jsx(RcDropdownContainer, { ...openProps, onOpenChange: emitOpenChange, size: size, ...restProps, className: className, overlay: _jsx(SelectOverlay, { size: size, multiple: multiple, disabled: disabled, loading: loading, filteredOptions: filteredOptions, selectedValues: selectedValues, triggerWidth: triggerWidth, highlightIndex: highlightIndex, listboxId: listboxId, notFoundContent: notFoundContent, popupClassName: popupClassName, optionRender: optionRender, dropdownRender: dropdownRender, onOptionSelect: handleOptionSelect, onHighlightChange: setHighlightIndex }), floatingContainerProps: { style: floatingStyle }, children: _jsxs(SelectField, { children: [_jsx(SelectInput, { ref: ref, controlRef: controlRef, id: inputId, ariaLabel: ariaLabel, ariaLabelledBy: ariaLabelledBy ?? (label ? labelId : undefined), ariaDescribedBy: [ariaDescribedBy, description ? descriptionId : undefined].filter(Boolean).join(" ") || undefined, disabled: disabled, searchable: searchable, multiple: multiple, size: size, status: fieldStatus, appearance: appearance, label: label, labelId: labelId, required: required, allowClear: allowClear, loading: loading, maxTagCount: maxTagCount, autoFocus: autoFocus, placeholder: placeholder ?? messages.select.placeholder, selectedOptions: selectedOptions, searchText: searchText, highlightIndex: highlightIndex, highlightedOption: highlightedOption, listboxId: listboxId, tagRender: tagRender, onSearchTextChange: handleSearchTextChange, onOpenStateChange: syncOpenState, onWidthChange: handleTriggerWidthChange, onMoveHighlight: moveHighlight, onSelectHighlighted: handleOptionSelect, onClear: handleClear, onRemoveTag: handleRemoveTag, onFocus: onFocus, onBlur: onBlur }), description && _jsx("div", { id: descriptionId, className: supportingStyle, "data-status": fieldStatus, role: errorText ? "alert" : undefined, children: description })] }) });
|
|
149
|
+
}
|
|
150
|
+
;
|
|
151
|
+
export default Select;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useDropdownContext } from "@crab-dev/rc-dropdown-container";
|
|
3
|
+
import RcLineEdit from "@crab-dev/rc-line-edit";
|
|
4
|
+
import Button from "@crab-dev/rc-button";
|
|
5
|
+
import { SpinIndicator } from "@crab-dev/rc-spin";
|
|
6
|
+
import Tag from "@crab-dev/rc-tag";
|
|
7
|
+
import { cx } from "@crab-dev/css";
|
|
8
|
+
import { useLocale } from "@crab-dev/rc-config-provider";
|
|
9
|
+
import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react";
|
|
10
|
+
import token, { vars } from "./token.mjs";
|
|
11
|
+
;
|
|
12
|
+
import { controlStyle, fieldControlStyle, fieldLayoutStyle, controlDisabledStyle, controlErrorStyle, controlWarningStyle, valueWrapStyle, singleValueStyle, placeholderStyle, searchInputStyle, caretStyle, caretOpenStyle, tagLabelStyle, suffixWrapStyle, actionStyle, loadingIconStyle } from "./selectInput.styles.mjs";
|
|
13
|
+
const setRef = (ref, node) => {
|
|
14
|
+
if (typeof ref === "function") {
|
|
15
|
+
ref(node);
|
|
16
|
+
} else if (ref) {
|
|
17
|
+
ref.current = node;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const sizeMetricsMap = { large: "rc-select-df4a743b55e1", medium: "rc-select-de4a743b542e", small: "rc-select-dd4a743b527b" };
|
|
21
|
+
const sizeHeightFixedMap = { large: "rc-select-b8fc5925a597", medium: "rc-select-b7fc5925a3e4", small: "rc-select-bafc5925a8fd" };
|
|
22
|
+
const sizeHeightFlexibleMap = { large: "rc-select-5d07f79a2f4a", medium: "rc-select-5e07f79a30fd", small: "rc-select-5b07f79a2be4" };
|
|
23
|
+
const CaretIcon = () => _jsx("svg", { "aria-hidden": "true", focusable: "false", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "m7 10 5 5 5-5z" }) });
|
|
24
|
+
const ClearIcon = () => _jsx("svg", { "aria-hidden": "true", focusable: "false", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "m18 6-12 12M6 6l12 12" }) });
|
|
25
|
+
;
|
|
26
|
+
const SelectInput = ({ ref, controlRef, id, ariaLabel, ariaLabelledBy, ariaDescribedBy, disabled, searchable, multiple, size, status, appearance, label, labelId, required, allowClear, loading, maxTagCount, autoFocus, placeholder, selectedOptions, searchText, highlightIndex, highlightedOption, listboxId, tagRender, onSearchTextChange, onOpenStateChange, onWidthChange, onMoveHighlight, onSelectHighlighted, onClear, onRemoveTag, onFocus, onBlur }) => {
|
|
27
|
+
const { state, refs, dispatch } = useDropdownContext();
|
|
28
|
+
const { messages } = useLocale();
|
|
29
|
+
const inputRef = useRef(null);
|
|
30
|
+
const open = state.open;
|
|
31
|
+
const [hovered, setHovered] = useState(false);
|
|
32
|
+
const [actionFocused, setActionFocused] = useState(false);
|
|
33
|
+
const [withoutHover, setWithoutHover] = useState(false);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const media = window.matchMedia?.("(hover: none)");
|
|
36
|
+
if (!media) return;
|
|
37
|
+
const update = () => setWithoutHover(media.matches);
|
|
38
|
+
update();
|
|
39
|
+
media.addEventListener("change", update);
|
|
40
|
+
return () => media.removeEventListener("change", update);
|
|
41
|
+
}, []);
|
|
42
|
+
const syncOpen = useEffectEvent(onOpenStateChange);
|
|
43
|
+
const mergeRef = useCallback((node) => {
|
|
44
|
+
controlRef.current = node;
|
|
45
|
+
if (typeof refs.setReference === "function") {
|
|
46
|
+
refs.setReference(node);
|
|
47
|
+
}
|
|
48
|
+
setRef(ref, node);
|
|
49
|
+
}, [refs.setReference, ref, controlRef]);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const el = controlRef.current;
|
|
52
|
+
if (!el) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const observer = new ResizeObserver((entries) => {
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
onWidthChange(entry.target.getBoundingClientRect().width);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
observer.observe(el);
|
|
61
|
+
return () => observer.disconnect();
|
|
62
|
+
}, [onWidthChange]);
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (open && searchable && !disabled) {
|
|
65
|
+
queueMicrotask(() => inputRef.current?.focus());
|
|
66
|
+
}
|
|
67
|
+
}, [disabled, open, searchable]);
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (autoFocus && !disabled && controlRef.current) {
|
|
70
|
+
controlRef.current.focus();
|
|
71
|
+
}
|
|
72
|
+
}, [autoFocus, disabled]);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
syncOpen(open);
|
|
75
|
+
}, [open]);
|
|
76
|
+
const handleClick = () => {
|
|
77
|
+
if (disabled) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
dispatch({ type: "setOpen", payload: !open });
|
|
81
|
+
};
|
|
82
|
+
const handleKeyDown = (e) => {
|
|
83
|
+
if (disabled) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
switch (e.key) {
|
|
87
|
+
case "ArrowDown":
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
if (!open) {
|
|
90
|
+
dispatch({ type: "setOpen", payload: true });
|
|
91
|
+
}
|
|
92
|
+
onMoveHighlight(1);
|
|
93
|
+
break;
|
|
94
|
+
case "ArrowUp":
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
if (!open) {
|
|
97
|
+
dispatch({ type: "setOpen", payload: true });
|
|
98
|
+
}
|
|
99
|
+
onMoveHighlight(-1);
|
|
100
|
+
break;
|
|
101
|
+
case "Enter":
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
if (open && highlightedOption) {
|
|
104
|
+
onSelectHighlighted(highlightedOption, e);
|
|
105
|
+
if (!multiple) {
|
|
106
|
+
dispatch({ type: "setOpen", payload: false });
|
|
107
|
+
}
|
|
108
|
+
} else if (!open) {
|
|
109
|
+
dispatch({ type: "setOpen", payload: true });
|
|
110
|
+
}
|
|
111
|
+
break;
|
|
112
|
+
case " ":
|
|
113
|
+
if (!searchable || !open) {
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
if (!open) {
|
|
116
|
+
dispatch({ type: "setOpen", payload: true });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
case "Escape":
|
|
121
|
+
if (open) {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
controlRef.current?.focus();
|
|
124
|
+
dispatch({ type: "setOpen", payload: false, details: { reason: "escape", event: e.nativeEvent } });
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
case "Backspace":
|
|
128
|
+
if (multiple && searchText === "" && selectedOptions.length > 0) {
|
|
129
|
+
const lastOption = selectedOptions[selectedOptions.length - 1];
|
|
130
|
+
onRemoveTag(lastOption.value, e);
|
|
131
|
+
}
|
|
132
|
+
break;
|
|
133
|
+
default:
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
const triggerClear = (event) => {
|
|
138
|
+
controlRef.current?.focus();
|
|
139
|
+
onClear(event);
|
|
140
|
+
if (open) {
|
|
141
|
+
dispatch({ type: "setOpen", payload: false });
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const handleClearClick = (e) => {
|
|
145
|
+
e.stopPropagation();
|
|
146
|
+
triggerClear(e);
|
|
147
|
+
};
|
|
148
|
+
const handleClearKeyDown = (e) => {
|
|
149
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
150
|
+
e.stopPropagation();
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
const canClear = allowClear && !disabled && selectedOptions.length > 0;
|
|
154
|
+
const showClear = canClear && (hovered || actionFocused || withoutHover);
|
|
155
|
+
const renderTag = (opt) => {
|
|
156
|
+
const handleClose = (event) => {
|
|
157
|
+
if (!disabled) onRemoveTag(opt.value, event);
|
|
158
|
+
};
|
|
159
|
+
if (tagRender) {
|
|
160
|
+
return _jsx("span", { children: tagRender(opt, handleClose) }, opt.value);
|
|
161
|
+
}
|
|
162
|
+
return _jsx(Tag, { size: "small", closable: !disabled, closeAriaLabel: messages.select.removeTag(typeof opt.label === "string" ? opt.label : opt.value), onClose: handleClose, children: _jsx("span", { className: tagLabelStyle, children: opt.label }) }, opt.value);
|
|
163
|
+
};
|
|
164
|
+
const renderTags = () => {
|
|
165
|
+
if (maxTagCount !== undefined && selectedOptions.length > maxTagCount) {
|
|
166
|
+
const visible = selectedOptions.slice(0, maxTagCount);
|
|
167
|
+
const overflowCount = selectedOptions.length - maxTagCount;
|
|
168
|
+
return _jsxs(_Fragment, { children: [visible.map(renderTag), _jsxs(Tag, { size: "small", children: ["+", overflowCount] })] });
|
|
169
|
+
}
|
|
170
|
+
return selectedOptions.map(renderTag);
|
|
171
|
+
};
|
|
172
|
+
const renderContent = () => {
|
|
173
|
+
if (searchable && open) {
|
|
174
|
+
return _jsxs(_Fragment, { children: [multiple && renderTags(), _jsx(RcLineEdit, { ref: inputRef, "aria-label": ariaLabel ?? label ?? placeholder, bordered: false, disabled: disabled, value: searchText, placeholder: multiple && selectedOptions.length > 0 ? "" : placeholder, onChange: (e) => onSearchTextChange(e.target.value), onClick: (e) => e.stopPropagation(), className: searchInputStyle })] });
|
|
175
|
+
}
|
|
176
|
+
if (multiple) {
|
|
177
|
+
if (selectedOptions.length === 0) {
|
|
178
|
+
return _jsx("span", { "data-role": "select-placeholder", className: placeholderStyle, children: placeholder });
|
|
179
|
+
}
|
|
180
|
+
return renderTags();
|
|
181
|
+
}
|
|
182
|
+
if (selectedOptions.length === 0) {
|
|
183
|
+
return _jsx("span", { "data-role": "select-placeholder", className: placeholderStyle, children: placeholder });
|
|
184
|
+
}
|
|
185
|
+
return _jsx("span", { className: singleValueStyle, children: selectedOptions[0]?.label });
|
|
186
|
+
};
|
|
187
|
+
const activeDescendantId = open && highlightIndex >= 0 ? `${listboxId}-option-${highlightIndex}` : undefined;
|
|
188
|
+
return _jsxs("div", { className: fieldLayoutStyle, "data-labeled": Boolean(label), "data-appearance": appearance, "data-floating": Boolean(open || selectedOptions.length > 0 || searchText), "data-open": open, "data-status": status, "data-disabled": disabled || undefined, children: [label && _jsxs("span", { "data-role": "select-label", children: [_jsx("span", { id: labelId, children: label }), required && _jsx("span", { "data-required-marker": "", "aria-hidden": "true", children: "*" })] }), _jsxs("div", { id: id, role: "combobox", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-required": required || undefined, "aria-expanded": open, "aria-disabled": disabled, "aria-busy": loading || undefined, "aria-haspopup": "listbox", "aria-controls": open ? listboxId : undefined, "aria-activedescendant": activeDescendantId, "aria-invalid": status === "error" ? true : undefined, "data-appearance": appearance, "data-clearable": canClear, "data-labeled": Boolean(label), "data-floating": Boolean(open || selectedOptions.length > 0 || searchText), "data-status": status, tabIndex: disabled ? -1 : 0, ref: mergeRef, className: cx.call(undefined, controlStyle, sizeMetricsMap[size], multiple ? sizeHeightFlexibleMap[size] : sizeHeightFixedMap[size], disabled && controlDisabledStyle, status === "error" && controlErrorStyle, status === "warning" && controlWarningStyle, fieldControlStyle), onClick: handleClick, onKeyDown: handleKeyDown, onFocus: onFocus, onBlur: onBlur, onPointerOver: (event) => {
|
|
189
|
+
if (event.pointerType !== "touch") setHovered(true);
|
|
190
|
+
}, onPointerOut: (event) => {
|
|
191
|
+
if (!(event.relatedTarget instanceof Node) || !event.currentTarget.contains(event.relatedTarget)) {
|
|
192
|
+
setHovered(false);
|
|
193
|
+
}
|
|
194
|
+
}, children: [label && appearance === "outlined" && _jsx("fieldset", { "aria-hidden": "true", children: _jsxs("legend", { children: [label, required ? " *" : ""] }) }), _jsx("div", { "data-role": "select-value", className: valueWrapStyle, children: renderContent() }), _jsxs("span", { "data-role": "select-suffix", className: suffixWrapStyle, children: [loading ? _jsx("span", { "aria-hidden": "true", className: loadingIconStyle, children: _jsx(SpinIndicator, {}) }) : null, _jsx(Button, { "data-role": showClear ? "select-clear" : "select-caret", className: actionStyle, type: "button", appearance: "text", shape: "circle", size: size === "small" ? "xs" : size === "large" ? "m" : "s", disabled: disabled, "aria-label": showClear ? messages.common.clear : open ? messages.select.closeOptions : messages.select.openOptions, "aria-expanded": showClear ? undefined : open, icon: showClear ? _jsx(ClearIcon, {}) : _jsx("span", { "aria-hidden": "true", className: cx.call(undefined, caretStyle, open && caretOpenStyle), children: _jsx(CaretIcon, {}) }), onFocus: () => setActionFocused(true), onBlur: () => setActionFocused(false), onClick: (event) => {
|
|
195
|
+
if (showClear) handleClearClick(event); else {
|
|
196
|
+
event.stopPropagation();
|
|
197
|
+
handleClick();
|
|
198
|
+
}
|
|
199
|
+
}, onKeyDown: handleClearKeyDown })] })] })] });
|
|
200
|
+
};
|
|
201
|
+
export default SelectInput;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { TokenVars as buttonVars } from "@crab-dev/rc-button";
|
|
2
|
+
import { TokenVars as spinVars } from "@crab-dev/rc-spin";
|
|
3
|
+
import semanticToken from "@crab-dev/rc-token-semantic";
|
|
4
|
+
import token, { vars } from "./token.mjs";
|
|
5
|
+
export const controlStyle = "rc-select-5e896678dc89";
|
|
6
|
+
export const fieldControlStyle = "rc-select-29edc097b42b";
|
|
7
|
+
export const fieldLayoutStyle = "rc-select-910e459fbbe0";
|
|
8
|
+
export const controlDisabledStyle = "rc-select-82fbf3807561";
|
|
9
|
+
export const controlErrorStyle = "rc-select-6d2a034155e7";
|
|
10
|
+
export const controlWarningStyle = "rc-select-36466465840d";
|
|
11
|
+
export const valueWrapStyle = "rc-select-f0b8a9c0c369";
|
|
12
|
+
export const singleValueStyle = "rc-select-bcd33343d38d";
|
|
13
|
+
export const placeholderStyle = "rc-select-30719d992205";
|
|
14
|
+
export const searchInputStyle = "rc-select-c91210473c12";
|
|
15
|
+
export const caretStyle = "rc-select-8ca15a371517";
|
|
16
|
+
export const caretOpenStyle = "rc-select-72a72768a0ef";
|
|
17
|
+
export const tagLabelStyle = "rc-select-113a9493f812";
|
|
18
|
+
export const suffixWrapStyle = "rc-select-5032f5885db7";
|
|
19
|
+
export const actionStyle = "rc-select-aa51540f6a1a";
|
|
20
|
+
export const loadingIconStyle = "rc-select-14f1c2e4ccd5";
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import Checkbox from "@crab-dev/rc-checkbox";
|
|
3
|
+
import { componentDensityMetrics, useConfig, useLocale } from "@crab-dev/rc-config-provider";
|
|
4
|
+
import { SpinIndicator } from "@crab-dev/rc-spin";
|
|
5
|
+
import RcVirtual from "@crab-dev/rc-virtual";
|
|
6
|
+
import { useMediaQuery } from "@crab-dev/rc-hooks";
|
|
7
|
+
import { cx } from "@crab-dev/css";
|
|
8
|
+
import { useDropdownContext } from "@crab-dev/rc-dropdown-container";
|
|
9
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
10
|
+
;
|
|
11
|
+
import { overlayStyle, optionStyle, optionHighlightStyle, measureStyle, optionSelectedStyle, optionSelectedSingleStyle, optionDisabledStyle, optionLabelStyle, checkIconStyle, checkboxIndicatorStyle, emptyStyle, groupLabelStyle, groupedOptionStyle, virtualTopSpacerStyle, virtualBottomSpacerStyle, loadingStyle } from "./selectOverlay.styles.mjs";
|
|
12
|
+
const hasGroups = (options) => options.some((opt) => opt.isGroupLabel);
|
|
13
|
+
const DROPDOWN_PADDING = 4;
|
|
14
|
+
const MAX_VIEWPORT_HEIGHT = 256;
|
|
15
|
+
;
|
|
16
|
+
const SelectOverlay = ({ size, multiple, disabled, loading, filteredOptions, selectedValues, triggerWidth, highlightIndex, listboxId, notFoundContent, popupClassName, optionRender, dropdownRender, onOptionSelect, onHighlightChange }) => {
|
|
17
|
+
const { dispatch } = useDropdownContext();
|
|
18
|
+
const coarsePointer = useMediaQuery("(pointer: coarse)");
|
|
19
|
+
const overlayRef = useRef(null);
|
|
20
|
+
const [measuredRowHeight, setMeasuredRowHeight] = useState(0);
|
|
21
|
+
const [measuredContentWidth, setMeasuredContentWidth] = useState(0);
|
|
22
|
+
const { density } = useConfig();
|
|
23
|
+
const { messages } = useLocale();
|
|
24
|
+
const rowHeight = Math.max(coarsePointer ? 48 : componentDensityMetrics[density].item, measuredRowHeight);
|
|
25
|
+
useLayoutEffect(() => {
|
|
26
|
+
const overlay = overlayRef.current;
|
|
27
|
+
const option = overlay?.querySelector("[data-select-option-measure]");
|
|
28
|
+
if (!overlay || !option) return;
|
|
29
|
+
const measure = () => {
|
|
30
|
+
const height = option.getBoundingClientRect().height;
|
|
31
|
+
if (height > 0) {
|
|
32
|
+
overlayRef.current?.style.setProperty("--select-option-measured-height", `${height}px`);
|
|
33
|
+
setMeasuredRowHeight(height);
|
|
34
|
+
}
|
|
35
|
+
const style = overlay.ownerDocument.defaultView?.getComputedStyle(overlay);
|
|
36
|
+
const paddingStart = Number.parseFloat(style?.paddingLeft ?? "") || 0;
|
|
37
|
+
const paddingEnd = Number.parseFloat(style?.paddingRight ?? "") || 0;
|
|
38
|
+
const width = overlay.clientWidth - paddingStart - paddingEnd;
|
|
39
|
+
if (width > 0) setMeasuredContentWidth(width);
|
|
40
|
+
};
|
|
41
|
+
measure();
|
|
42
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
43
|
+
const observer = new ResizeObserver(measure);
|
|
44
|
+
observer.observe(option);
|
|
45
|
+
observer.observe(overlay);
|
|
46
|
+
return () => observer.disconnect();
|
|
47
|
+
}, [coarsePointer, density, filteredOptions.length, loading, size]);
|
|
48
|
+
const gridRef = useRef(null);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (highlightIndex < 0) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
gridRef.current?.scrollToCell({ rowIndex: highlightIndex });
|
|
54
|
+
}, [highlightIndex]);
|
|
55
|
+
const rowCount = filteredOptions.length;
|
|
56
|
+
const contentWidth = measuredContentWidth || Math.max(triggerWidth - DROPDOWN_PADDING * 2, 160);
|
|
57
|
+
const viewportHeight = Math.min(MAX_VIEWPORT_HEIGHT, rowCount * rowHeight);
|
|
58
|
+
const rows = Array(rowCount).fill(rowHeight);
|
|
59
|
+
const columns = [contentWidth];
|
|
60
|
+
if (loading) {
|
|
61
|
+
return _jsx("div", { id: listboxId, role: "listbox", "aria-busy": "true", "data-select-overlay": true, className: cx.call(undefined, overlayStyle, popupClassName), children: _jsx("div", { className: loadingStyle, children: _jsx(SpinIndicator, {}) }) });
|
|
62
|
+
}
|
|
63
|
+
if (filteredOptions.length === 0) {
|
|
64
|
+
return _jsx("div", { id: listboxId, role: "listbox", "data-select-overlay": true, className: cx.call(undefined, overlayStyle, popupClassName), children: _jsx("div", { className: emptyStyle, children: notFoundContent ?? messages.select.empty }) });
|
|
65
|
+
}
|
|
66
|
+
const isGrouped = hasGroups(filteredOptions);
|
|
67
|
+
const menu = _jsx(RcVirtual, { gridRef: gridRef, viewportWidth: contentWidth, viewportHeight: viewportHeight, gridTemplateColumns: columns, gridTemplateRows: rows, overscanRowCount: 4, renderRows: (rowRange) => {
|
|
68
|
+
const nodes = [_jsx("div", { "data-select-virtual-spacer": "top", className: virtualTopSpacerStyle }, "__select-top-padding__")];
|
|
69
|
+
for (let i = rowRange[0]; i <= rowRange[1]; i += 1) {
|
|
70
|
+
const option = filteredOptions[i];
|
|
71
|
+
if (option.isGroupLabel) {
|
|
72
|
+
nodes.push(_jsx("div", { "data-group-label": true, className: groupLabelStyle, children: option.label }, option.value));
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const selected = selectedValues.includes(option.value);
|
|
76
|
+
const highlighted = i === highlightIndex;
|
|
77
|
+
const content = optionRender ? optionRender(option, { selected }) : _jsxs(_Fragment, { children: [multiple ? _jsx(Checkbox, { className: checkboxIndicatorStyle, checked: selected, disabled: disabled || option.disabled, tabIndex: -1, "aria-label": typeof option.label === "string" ? option.label : option.value }) : null, _jsx("span", { className: optionLabelStyle, children: option.label }), !multiple && selected ? _jsx("span", { className: checkIconStyle, children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M20 6 9 17l-5-5" }) }) }) : null] });
|
|
78
|
+
const optionAriaLabel = multiple ? typeof option.label === "string" ? option.label : option.value : undefined;
|
|
79
|
+
nodes.push(_jsx("div", { id: `${listboxId}-option-${i}`, role: "option", "aria-selected": selected, "aria-disabled": disabled || option.disabled || undefined, "data-highlighted": highlighted || undefined, "aria-label": optionAriaLabel, className: cx.call(undefined, optionStyle, isGrouped && groupedOptionStyle, selected && (multiple ? optionSelectedStyle : optionSelectedSingleStyle), highlighted && optionHighlightStyle, (disabled || option.disabled) && optionDisabledStyle), onMouseEnter: () => {
|
|
80
|
+
if (!disabled) onHighlightChange(i);
|
|
81
|
+
}, onClick: (event) => {
|
|
82
|
+
if (disabled) return;
|
|
83
|
+
onOptionSelect(option, event);
|
|
84
|
+
if (!multiple && !option.disabled) {
|
|
85
|
+
dispatch({ type: "setOpen", payload: false });
|
|
86
|
+
}
|
|
87
|
+
}, children: content }, option.value));
|
|
88
|
+
}
|
|
89
|
+
nodes.push(_jsx("div", { "data-select-virtual-spacer": "bottom", className: virtualBottomSpacerStyle }, "__select-bottom-padding__"));
|
|
90
|
+
return nodes;
|
|
91
|
+
} });
|
|
92
|
+
return _jsxs("div", { id: listboxId, ref: overlayRef, "data-select-overlay": true, className: cx.call(undefined, overlayStyle, popupClassName), role: "listbox", "aria-multiselectable": multiple || undefined, "aria-disabled": disabled || undefined, children: [_jsx("div", { "aria-hidden": "true", "data-select-option-measure": "", className: cx(optionStyle, measureStyle), children: "M" }), dropdownRender ? dropdownRender(menu) : menu] });
|
|
93
|
+
};
|
|
94
|
+
export default SelectOverlay;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { TokenVars as checkboxVars } from "@crab-dev/rc-checkbox";
|
|
2
|
+
import { TokenVars as spinVars } from "@crab-dev/rc-spin";
|
|
3
|
+
import token from "./token.mjs";
|
|
4
|
+
export const overlayStyle = "rc-select-2b62562b9766";
|
|
5
|
+
export const optionStyle = "rc-select-4c26800476b7";
|
|
6
|
+
export const optionHighlightStyle = "rc-select-868bf53a2d33";
|
|
7
|
+
export const measureStyle = "rc-select-f4efdbe79bde";
|
|
8
|
+
export const optionSelectedStyle = "rc-select-dca4819751f4";
|
|
9
|
+
export const optionSelectedSingleStyle = "rc-select-b8e7cfd3837c";
|
|
10
|
+
export const optionDisabledStyle = "rc-select-fd6a969fdc3f";
|
|
11
|
+
export const optionLabelStyle = "rc-select-d58ef5724e7d";
|
|
12
|
+
export const checkIconStyle = "rc-select-bbb208831319";
|
|
13
|
+
export const checkboxIndicatorStyle = "rc-select-52f500f177fc";
|
|
14
|
+
export const emptyStyle = "rc-select-e84bd23bdc99";
|
|
15
|
+
export const groupLabelStyle = "rc-select-3ce923bc7749";
|
|
16
|
+
export const groupedOptionStyle = "rc-select-16181d2683cf";
|
|
17
|
+
export const virtualTopSpacerStyle = "rc-select-7804943b918e";
|
|
18
|
+
export const virtualBottomSpacerStyle = "rc-select-86002077b8c2";
|
|
19
|
+
export const loadingStyle = "rc-select-3809e79e5752";
|