seven-design-ui 0.0.1 → 0.0.2
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/docs/components/cascader.mdx +413 -0
- package/docs/doc_build/404.html +1 -1
- package/docs/doc_build/components/button.html +2 -2
- package/docs/doc_build/components/cascader.html +45 -0
- package/docs/doc_build/components/input.html +2 -2
- package/docs/doc_build/components/pagination.html +3 -3
- package/docs/doc_build/components/switch.html +3 -3
- package/docs/doc_build/guide/introduction.html +2 -2
- package/docs/doc_build/guide/quick-start.html +1 -1
- package/docs/doc_build/guide/theme.html +1 -1
- package/docs/doc_build/index.html +1 -1
- package/docs/doc_build/static/css/{styles.5a3e7113.css → styles.fc43cac8.css} +1 -1
- package/docs/doc_build/static/js/414.7fd9a017.js +6 -0
- package/docs/doc_build/static/js/async/166.656253c5.js +2 -0
- package/docs/doc_build/static/js/async/252.c7b023ea.js +1 -0
- package/docs/doc_build/static/js/async/{637.cb5d76c9.js → 637.9d5c88dd.js} +1 -1
- package/docs/doc_build/static/js/index.366ff9c0.js +1 -0
- package/docs/doc_build/static/search_index.69d54449.json +1 -0
- package/docs/guide/introduction.md +1 -1
- package/docs/package.json +2 -1
- package/docs/rspress.config.ts +5 -1
- package/package.json +2 -2
- package/packages/components/package.json +1 -1
- package/packages/components/src/cascader/Cascader.tsx +638 -0
- package/packages/components/src/cascader/cascader.css +381 -0
- package/packages/components/src/cascader/index.ts +1 -0
- package/packages/components/src/index.ts +1 -0
- package/play/src/App.tsx +74 -1
- package/play/src/index.css +2 -0
- package/play/src/options/basic.ts +32 -0
- package/play/src/options/disabled.ts +25 -0
- package/play/src/options/hover.ts +18 -0
- package/play/src/options/index.ts +4 -0
- package/play/src/options/multiple.ts +39 -0
- package/docs/doc_build/static/js/414.04bb58dd.js +0 -6
- package/docs/doc_build/static/js/async/166.f43be01a.js +0 -2
- package/docs/doc_build/static/js/index.0991c749.js +0 -1
- package/docs/doc_build/static/search_index.72c9c372.json +0 -1
- /package/docs/doc_build/static/js/{414.04bb58dd.js.LICENSE.txt → 414.7fd9a017.js.LICENSE.txt} +0 -0
- /package/docs/doc_build/static/js/async/{166.f43be01a.js.LICENSE.txt → 166.656253c5.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
import { forwardRef, useState, useCallback, useMemo, useRef, useEffect, type MutableRefObject } from 'react'
|
|
2
|
+
import { classnames } from '@seven-design-ui/core'
|
|
3
|
+
import './cascader.css'
|
|
4
|
+
|
|
5
|
+
// 级联选择器的选项类型
|
|
6
|
+
export interface CascaderOption {
|
|
7
|
+
/** 选项的值 */
|
|
8
|
+
value: string | number
|
|
9
|
+
/** 选项的显示文本 */
|
|
10
|
+
label: string
|
|
11
|
+
/** 子选项 */
|
|
12
|
+
children?: CascaderOption[]
|
|
13
|
+
/** 是否禁用 */
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
/** 是否禁用复选框(多选模式下) */
|
|
16
|
+
disableCheckbox?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 级联选择器的属性类型
|
|
20
|
+
export interface CascaderProps {
|
|
21
|
+
/** 选项数据 */
|
|
22
|
+
options: CascaderOption[]
|
|
23
|
+
/** 展开触发方式 */
|
|
24
|
+
expandTrigger?: 'click' | 'hover'
|
|
25
|
+
/** 是否可清空 */
|
|
26
|
+
clearable?: boolean
|
|
27
|
+
/** 是否禁用 */
|
|
28
|
+
disabled?: boolean
|
|
29
|
+
/** 默认值 */
|
|
30
|
+
defaultValue?: (string | number)[] | (string | number)
|
|
31
|
+
/** 当前值(受控) */
|
|
32
|
+
value?: (string | number)[] | (string | number)
|
|
33
|
+
/** 值改变时的回调 */
|
|
34
|
+
onChange?: (value: (string | number)[] | (string | number) | undefined, selectedOptions: CascaderOption[] | CascaderOption | undefined) => void
|
|
35
|
+
/** 是否显示完整路径 */
|
|
36
|
+
showAllLevels?: boolean
|
|
37
|
+
/** 是否支持多选 */
|
|
38
|
+
multiple?: boolean
|
|
39
|
+
/** 是否支持搜索 */
|
|
40
|
+
showSearch?: boolean
|
|
41
|
+
/** 自定义搜索函数 */
|
|
42
|
+
filterOption?: (inputValue: string, option: CascaderOption) => boolean
|
|
43
|
+
/** 占位符 */
|
|
44
|
+
placeholder?: string
|
|
45
|
+
/** 搜索占位符 */
|
|
46
|
+
searchPlaceholder?: string
|
|
47
|
+
/** 自定义类名 */
|
|
48
|
+
className?: string
|
|
49
|
+
/** 自定义样式 */
|
|
50
|
+
style?: React.CSSProperties
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 内部使用的选中项类型
|
|
54
|
+
interface SelectedItem {
|
|
55
|
+
value: string | number
|
|
56
|
+
label: string
|
|
57
|
+
path: CascaderOption[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const Cascader = forwardRef<HTMLDivElement, CascaderProps>((props, ref) => {
|
|
61
|
+
const {
|
|
62
|
+
options,
|
|
63
|
+
expandTrigger = 'click',
|
|
64
|
+
clearable = false,
|
|
65
|
+
disabled = false,
|
|
66
|
+
defaultValue,
|
|
67
|
+
value: controlledValue,
|
|
68
|
+
onChange,
|
|
69
|
+
showAllLevels = true,
|
|
70
|
+
multiple = false,
|
|
71
|
+
showSearch = false,
|
|
72
|
+
filterOption,
|
|
73
|
+
placeholder = '请选择',
|
|
74
|
+
searchPlaceholder = '搜索选项',
|
|
75
|
+
className,
|
|
76
|
+
style,
|
|
77
|
+
...rest
|
|
78
|
+
} = props
|
|
79
|
+
|
|
80
|
+
// 内部状态管理
|
|
81
|
+
const [internalValue, setInternalValue] = useState<(string | number)[] | (string | number) | undefined>(defaultValue)
|
|
82
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
83
|
+
const [activePath, setActivePath] = useState<CascaderOption[]>([])
|
|
84
|
+
const [hoveredPath, setHoveredPath] = useState<CascaderOption[]>([])
|
|
85
|
+
const [focusedIndex, setFocusedIndex] = useState<number>(-1)
|
|
86
|
+
const [focusedLevel, setFocusedLevel] = useState<number>(0)
|
|
87
|
+
const [searchValue, setSearchValue] = useState<string>('')
|
|
88
|
+
|
|
89
|
+
// 引用:容器包含触发器+下拉面板,用于点击外部检测;inputRef 仅触发器
|
|
90
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
91
|
+
const inputRef = useRef<HTMLDivElement>(null)
|
|
92
|
+
|
|
93
|
+
// 当前值(受控或内部)
|
|
94
|
+
const value = controlledValue !== undefined ? controlledValue : internalValue
|
|
95
|
+
|
|
96
|
+
// 将值转换为数组格式(便于处理)
|
|
97
|
+
const valueArray = useMemo(() => {
|
|
98
|
+
if (multiple) {
|
|
99
|
+
return Array.isArray(value) ? value : (value ? [value] : [])
|
|
100
|
+
} else {
|
|
101
|
+
return value ? (Array.isArray(value) ? [value[0]] : [value]) : []
|
|
102
|
+
}
|
|
103
|
+
}, [value, multiple])
|
|
104
|
+
|
|
105
|
+
// 根据值找到对应的选项路径
|
|
106
|
+
const selectedItems = useMemo((): SelectedItem[] => {
|
|
107
|
+
const findOptionsByValues = (values: (string | number)[]): SelectedItem[] => {
|
|
108
|
+
const result: SelectedItem[] = []
|
|
109
|
+
|
|
110
|
+
const findPath = (options: CascaderOption[], targetValue: string | number, currentPath: CascaderOption[] = []): CascaderOption[] | null => {
|
|
111
|
+
for (const option of options) {
|
|
112
|
+
const newPath = [...currentPath, option]
|
|
113
|
+
if (option.value === targetValue) {
|
|
114
|
+
return newPath
|
|
115
|
+
}
|
|
116
|
+
if (option.children) {
|
|
117
|
+
const childPath = findPath(option.children, targetValue, newPath)
|
|
118
|
+
if (childPath) {
|
|
119
|
+
return childPath
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const val of values) {
|
|
127
|
+
const path = findPath(options, val)
|
|
128
|
+
if (path) {
|
|
129
|
+
const lastOption = path[path.length - 1]
|
|
130
|
+
result.push({
|
|
131
|
+
value: val,
|
|
132
|
+
label: lastOption.label,
|
|
133
|
+
path: path
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return result
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return findOptionsByValues(valueArray)
|
|
142
|
+
}, [valueArray, options])
|
|
143
|
+
|
|
144
|
+
// 显示文本
|
|
145
|
+
const displayText = useMemo(() => {
|
|
146
|
+
if (selectedItems.length === 0) {
|
|
147
|
+
return ''
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (multiple) {
|
|
151
|
+
// 多选模式:显示所有选中项的标签
|
|
152
|
+
return selectedItems.map(item => item.label).join(', ')
|
|
153
|
+
} else {
|
|
154
|
+
// 单选模式:根据 showAllLevels 决定显示完整路径还是最后一级
|
|
155
|
+
const item = selectedItems[0]
|
|
156
|
+
if (showAllLevels) {
|
|
157
|
+
return item.path.map(opt => opt.label).join(' / ')
|
|
158
|
+
} else {
|
|
159
|
+
return item.label
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}, [selectedItems, multiple, showAllLevels])
|
|
163
|
+
|
|
164
|
+
// 处理选项点击
|
|
165
|
+
const handleOptionClick = useCallback((option: CascaderOption, path: CascaderOption[]) => {
|
|
166
|
+
if (option.disabled) return
|
|
167
|
+
|
|
168
|
+
if (multiple) {
|
|
169
|
+
// 多选模式
|
|
170
|
+
const currentValues = valueArray
|
|
171
|
+
const isSelected = currentValues.includes(option.value)
|
|
172
|
+
|
|
173
|
+
let newValues: (string | number)[]
|
|
174
|
+
if (isSelected) {
|
|
175
|
+
// 取消选中
|
|
176
|
+
newValues = currentValues.filter(v => v !== option.value)
|
|
177
|
+
} else {
|
|
178
|
+
// 添加选中
|
|
179
|
+
newValues = [...currentValues, option.value]
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const newValue = multiple ? newValues : newValues[0]
|
|
183
|
+
if (controlledValue === undefined) {
|
|
184
|
+
setInternalValue(newValue)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 直接构造选中的选项,避免使用过时的selectedItems
|
|
188
|
+
const selectedOptions = newValues.map(val => {
|
|
189
|
+
const findOption = (options: CascaderOption[]): CascaderOption | null => {
|
|
190
|
+
for (const opt of options) {
|
|
191
|
+
if (opt.value === val) return opt
|
|
192
|
+
if (opt.children) {
|
|
193
|
+
const childResult = findOption(opt.children)
|
|
194
|
+
if (childResult) return childResult
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return null
|
|
198
|
+
}
|
|
199
|
+
return findOption(options)
|
|
200
|
+
}).filter(Boolean) as CascaderOption[]
|
|
201
|
+
|
|
202
|
+
onChange?.(newValue, selectedOptions.length > 0 ? selectedOptions : (multiple ? [] : undefined))
|
|
203
|
+
|
|
204
|
+
// 如果有子选项,展开子菜单
|
|
205
|
+
if (option.children && !isSelected) {
|
|
206
|
+
setActivePath(path)
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
// 单选模式
|
|
210
|
+
if (option.children) {
|
|
211
|
+
// 有子选项,展开
|
|
212
|
+
setActivePath(path)
|
|
213
|
+
} else {
|
|
214
|
+
// 没有子选项,选择并关闭
|
|
215
|
+
const newValue = option.value
|
|
216
|
+
if (controlledValue === undefined) {
|
|
217
|
+
setInternalValue(newValue)
|
|
218
|
+
}
|
|
219
|
+
onChange?.(newValue, option)
|
|
220
|
+
setIsOpen(false)
|
|
221
|
+
setActivePath([])
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}, [multiple, valueArray, controlledValue, onChange, options])
|
|
225
|
+
|
|
226
|
+
// 处理鼠标悬停
|
|
227
|
+
const handleOptionHover = useCallback((option: CascaderOption, path: CascaderOption[]) => {
|
|
228
|
+
if (expandTrigger === 'hover' && option.children) {
|
|
229
|
+
setHoveredPath(path)
|
|
230
|
+
}
|
|
231
|
+
}, [expandTrigger])
|
|
232
|
+
|
|
233
|
+
// 当前激活的路径(用于显示子菜单)
|
|
234
|
+
const currentActivePath = useMemo(() => {
|
|
235
|
+
if (expandTrigger === 'hover') {
|
|
236
|
+
return hoveredPath
|
|
237
|
+
} else {
|
|
238
|
+
return activePath
|
|
239
|
+
}
|
|
240
|
+
}, [expandTrigger, hoveredPath, activePath])
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
// 处理输入框点击
|
|
244
|
+
const handleInputClick = useCallback(() => {
|
|
245
|
+
if (disabled) return
|
|
246
|
+
setIsOpen(!isOpen)
|
|
247
|
+
}, [disabled, isOpen])
|
|
248
|
+
|
|
249
|
+
// 处理清空
|
|
250
|
+
const handleClear = useCallback((e: React.MouseEvent) => {
|
|
251
|
+
e.stopPropagation()
|
|
252
|
+
const newValue = multiple ? [] : undefined
|
|
253
|
+
if (controlledValue === undefined) {
|
|
254
|
+
setInternalValue(newValue)
|
|
255
|
+
}
|
|
256
|
+
onChange?.(newValue, multiple ? [] : undefined)
|
|
257
|
+
setIsOpen(false)
|
|
258
|
+
setActivePath([])
|
|
259
|
+
}, [multiple, controlledValue, onChange])
|
|
260
|
+
|
|
261
|
+
// 处理标签关闭(多选模式)
|
|
262
|
+
const handleTagClose = useCallback((tagValue: string | number, e: React.MouseEvent) => {
|
|
263
|
+
e.stopPropagation()
|
|
264
|
+
const newValues = valueArray.filter(v => v !== tagValue)
|
|
265
|
+
const newValue = multiple ? newValues : newValues[0] || undefined
|
|
266
|
+
if (controlledValue === undefined) {
|
|
267
|
+
setInternalValue(newValue)
|
|
268
|
+
}
|
|
269
|
+
const selectedOptions = selectedItems.filter(item => newValues.includes(item.value)).map(item => item.path[item.path.length - 1])
|
|
270
|
+
onChange?.(newValue, selectedOptions.length > 0 ? selectedOptions : (multiple ? [] : undefined))
|
|
271
|
+
}, [valueArray, multiple, controlledValue, onChange, selectedItems])
|
|
272
|
+
|
|
273
|
+
// 默认搜索过滤函数
|
|
274
|
+
const defaultFilterOption = useCallback((inputValue: string, option: CascaderOption): boolean => {
|
|
275
|
+
return option.label.toLowerCase().includes(inputValue.toLowerCase())
|
|
276
|
+
}, [])
|
|
277
|
+
|
|
278
|
+
// 搜索过滤选项
|
|
279
|
+
const filterOptions = useCallback((options: CascaderOption[], searchValue: string): CascaderOption[] => {
|
|
280
|
+
if (!searchValue) return options
|
|
281
|
+
|
|
282
|
+
const filter = filterOption || defaultFilterOption
|
|
283
|
+
|
|
284
|
+
const filterRecursively = (opts: CascaderOption[]): CascaderOption[] => {
|
|
285
|
+
return opts
|
|
286
|
+
.map(option => {
|
|
287
|
+
// 检查当前选项是否匹配
|
|
288
|
+
const currentMatch = filter(searchValue, option)
|
|
289
|
+
|
|
290
|
+
// 递归检查子选项
|
|
291
|
+
let filteredChildren: CascaderOption[] = []
|
|
292
|
+
if (option.children) {
|
|
293
|
+
filteredChildren = filterRecursively(option.children)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 如果当前选项或任何子选项匹配,则包含此选项
|
|
297
|
+
if (currentMatch || filteredChildren.length > 0) {
|
|
298
|
+
return {
|
|
299
|
+
...option,
|
|
300
|
+
children: filteredChildren.length > 0 ? filteredChildren : option.children
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return null
|
|
305
|
+
})
|
|
306
|
+
.filter(Boolean) as CascaderOption[]
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return filterRecursively(options)
|
|
310
|
+
}, [filterOption, defaultFilterOption])
|
|
311
|
+
|
|
312
|
+
// 获取当前级别的选项(支持搜索)
|
|
313
|
+
const getCurrentLevelOptions = useCallback((level: number): CascaderOption[] => {
|
|
314
|
+
let baseOptions = options
|
|
315
|
+
if (searchValue) {
|
|
316
|
+
baseOptions = filterOptions(options, searchValue)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (level === 0) return baseOptions
|
|
320
|
+
if (currentActivePath.length >= level) {
|
|
321
|
+
return currentActivePath[level - 1]?.children || []
|
|
322
|
+
}
|
|
323
|
+
return []
|
|
324
|
+
}, [options, currentActivePath, searchValue, filterOptions])
|
|
325
|
+
|
|
326
|
+
// 处理键盘导航
|
|
327
|
+
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
|
328
|
+
if (!isOpen) return
|
|
329
|
+
|
|
330
|
+
const currentOptions = getCurrentLevelOptions(focusedLevel)
|
|
331
|
+
|
|
332
|
+
switch (event.key) {
|
|
333
|
+
case 'Escape':
|
|
334
|
+
setIsOpen(false)
|
|
335
|
+
setActivePath([])
|
|
336
|
+
setHoveredPath([])
|
|
337
|
+
setFocusedIndex(-1)
|
|
338
|
+
setFocusedLevel(0)
|
|
339
|
+
setSearchValue('')
|
|
340
|
+
break
|
|
341
|
+
|
|
342
|
+
case 'ArrowDown':
|
|
343
|
+
event.preventDefault()
|
|
344
|
+
if (currentOptions.length > 0) {
|
|
345
|
+
const nextIndex = focusedIndex < currentOptions.length - 1 ? focusedIndex + 1 : 0
|
|
346
|
+
setFocusedIndex(nextIndex)
|
|
347
|
+
// 模拟悬停效果
|
|
348
|
+
const option = currentOptions[nextIndex]
|
|
349
|
+
if (option && expandTrigger === 'hover') {
|
|
350
|
+
const path = focusedLevel === 0 ? [option] : [...currentActivePath.slice(0, focusedLevel), option]
|
|
351
|
+
setHoveredPath(path)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
break
|
|
355
|
+
|
|
356
|
+
case 'ArrowUp':
|
|
357
|
+
event.preventDefault()
|
|
358
|
+
if (currentOptions.length > 0) {
|
|
359
|
+
const nextIndex = focusedIndex > 0 ? focusedIndex - 1 : currentOptions.length - 1
|
|
360
|
+
setFocusedIndex(nextIndex)
|
|
361
|
+
// 模拟悬停效果
|
|
362
|
+
const option = currentOptions[nextIndex]
|
|
363
|
+
if (option && expandTrigger === 'hover') {
|
|
364
|
+
const path = focusedLevel === 0 ? [option] : [...currentActivePath.slice(0, focusedLevel), option]
|
|
365
|
+
setHoveredPath(path)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
break
|
|
369
|
+
|
|
370
|
+
case 'ArrowRight':
|
|
371
|
+
event.preventDefault()
|
|
372
|
+
if (focusedIndex >= 0 && currentOptions[focusedIndex]?.children) {
|
|
373
|
+
// 移动到子菜单
|
|
374
|
+
const option = currentOptions[focusedIndex]
|
|
375
|
+
const path = focusedLevel === 0 ? [option] : [...currentActivePath.slice(0, focusedLevel), option]
|
|
376
|
+
setActivePath(path)
|
|
377
|
+
setFocusedLevel(focusedLevel + 1)
|
|
378
|
+
setFocusedIndex(0)
|
|
379
|
+
}
|
|
380
|
+
break
|
|
381
|
+
|
|
382
|
+
case 'ArrowLeft':
|
|
383
|
+
event.preventDefault()
|
|
384
|
+
if (focusedLevel > 0) {
|
|
385
|
+
// 移动到父菜单
|
|
386
|
+
setActivePath(currentActivePath.slice(0, focusedLevel - 1))
|
|
387
|
+
setFocusedLevel(focusedLevel - 1)
|
|
388
|
+
setFocusedIndex(-1)
|
|
389
|
+
}
|
|
390
|
+
break
|
|
391
|
+
|
|
392
|
+
case 'Enter':
|
|
393
|
+
event.preventDefault()
|
|
394
|
+
if (focusedIndex >= 0 && currentOptions[focusedIndex]) {
|
|
395
|
+
const option = currentOptions[focusedIndex]
|
|
396
|
+
const path = focusedLevel === 0 ? [] : currentActivePath.slice(0, focusedLevel)
|
|
397
|
+
handleOptionClick(option, [...path, option])
|
|
398
|
+
}
|
|
399
|
+
break
|
|
400
|
+
}
|
|
401
|
+
}, [isOpen, focusedIndex, focusedLevel, getCurrentLevelOptions, expandTrigger, currentActivePath, handleOptionClick, setFocusedIndex, setFocusedLevel, setActivePath, setIsOpen, setHoveredPath, setSearchValue])
|
|
402
|
+
|
|
403
|
+
// 点击外部关闭下拉框(必须用容器 ref:面板与触发器是兄弟,不在 inputRef 内)
|
|
404
|
+
useEffect(() => {
|
|
405
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
406
|
+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
407
|
+
setIsOpen(false)
|
|
408
|
+
setActivePath([])
|
|
409
|
+
setHoveredPath([])
|
|
410
|
+
setFocusedIndex(-1)
|
|
411
|
+
setFocusedLevel(0)
|
|
412
|
+
setSearchValue('')
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (isOpen) {
|
|
417
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
418
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
419
|
+
return () => {
|
|
420
|
+
document.removeEventListener('mousedown', handleClickOutside)
|
|
421
|
+
document.removeEventListener('keydown', handleKeyDown)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}, [isOpen, handleKeyDown])
|
|
425
|
+
|
|
426
|
+
// 渲染菜单项
|
|
427
|
+
const renderMenuItem = useCallback((
|
|
428
|
+
option: CascaderOption,
|
|
429
|
+
path: CascaderOption[],
|
|
430
|
+
levelIndex: number,
|
|
431
|
+
itemIndex: number
|
|
432
|
+
) => {
|
|
433
|
+
const isSelected = valueArray.includes(option.value)
|
|
434
|
+
const hasChildren = option.children && option.children.length > 0
|
|
435
|
+
const isInActivePath = activePath.some(activeOption => activeOption.value === option.value)
|
|
436
|
+
const isFocused = focusedLevel === levelIndex && focusedIndex === itemIndex
|
|
437
|
+
|
|
438
|
+
const itemClasses = classnames('sd-cascader__menu-item', {
|
|
439
|
+
'is-active': isSelected,
|
|
440
|
+
'is-disabled': option.disabled,
|
|
441
|
+
'in-active-path': isInActivePath,
|
|
442
|
+
'is-focused': isFocused
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
return (
|
|
446
|
+
<li
|
|
447
|
+
key={option.value}
|
|
448
|
+
className={itemClasses}
|
|
449
|
+
onClick={(e) => {
|
|
450
|
+
e.stopPropagation()
|
|
451
|
+
handleOptionClick(option, [...path, option])
|
|
452
|
+
}}
|
|
453
|
+
onMouseEnter={() => {
|
|
454
|
+
handleOptionHover(option, [...path, option])
|
|
455
|
+
// 清除键盘聚焦状态
|
|
456
|
+
setFocusedIndex(-1)
|
|
457
|
+
}}
|
|
458
|
+
>
|
|
459
|
+
{multiple && (
|
|
460
|
+
<div className="sd-cascader__menu-item-prefix">
|
|
461
|
+
<div
|
|
462
|
+
className={classnames('sd-cascader__menu-item-checkbox', {
|
|
463
|
+
'is-checked': isSelected,
|
|
464
|
+
'is-disabled': option.disableCheckbox || option.disabled
|
|
465
|
+
})}
|
|
466
|
+
>
|
|
467
|
+
{isSelected && <span></span>}
|
|
468
|
+
</div>
|
|
469
|
+
</div>
|
|
470
|
+
)}
|
|
471
|
+
<span className="sd-cascader__menu-item-label">{option.label}</span>
|
|
472
|
+
{hasChildren && (
|
|
473
|
+
<div className="sd-cascader__menu-item-postfix">
|
|
474
|
+
<span className="sd-cascader__menu-item-arrow">›</span>
|
|
475
|
+
</div>
|
|
476
|
+
)}
|
|
477
|
+
</li>
|
|
478
|
+
)
|
|
479
|
+
}, [valueArray, activePath, multiple, focusedLevel, focusedIndex, handleOptionClick, handleOptionHover])
|
|
480
|
+
|
|
481
|
+
// 渲染菜单
|
|
482
|
+
const renderMenu = useCallback((
|
|
483
|
+
options: CascaderOption[],
|
|
484
|
+
path: CascaderOption[] = [],
|
|
485
|
+
levelIndex: number = 0
|
|
486
|
+
) => {
|
|
487
|
+
if (!options || options.length === 0) {
|
|
488
|
+
return (
|
|
489
|
+
<div className="sd-cascader__empty">
|
|
490
|
+
无数据
|
|
491
|
+
</div>
|
|
492
|
+
)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return (
|
|
496
|
+
<div className="sd-cascader__menu">
|
|
497
|
+
<ul className="sd-cascader__menu-list">
|
|
498
|
+
{options.map((option, itemIndex) => renderMenuItem(option, path, levelIndex, itemIndex))}
|
|
499
|
+
</ul>
|
|
500
|
+
</div>
|
|
501
|
+
)
|
|
502
|
+
}, [renderMenuItem])
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
// CSS 类名
|
|
506
|
+
const containerClasses = classnames('sd-cascader', className)
|
|
507
|
+
const inputClasses = classnames('sd-cascader__input', {
|
|
508
|
+
'is-disabled': disabled,
|
|
509
|
+
'is-expanded': isOpen
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
// 合并容器 ref 与 forwarded ref,供点击外部检测与父组件使用
|
|
513
|
+
const setContainerRef = useCallback(
|
|
514
|
+
(el: HTMLDivElement | null) => {
|
|
515
|
+
(containerRef as MutableRefObject<HTMLDivElement | null>).current = el
|
|
516
|
+
if (typeof ref === 'function') ref(el)
|
|
517
|
+
else if (ref) (ref as MutableRefObject<HTMLDivElement | null>).current = el
|
|
518
|
+
},
|
|
519
|
+
[ref]
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
return (
|
|
523
|
+
<div
|
|
524
|
+
ref={setContainerRef}
|
|
525
|
+
className={containerClasses}
|
|
526
|
+
style={style}
|
|
527
|
+
{...rest}
|
|
528
|
+
>
|
|
529
|
+
<div
|
|
530
|
+
ref={inputRef}
|
|
531
|
+
className={inputClasses}
|
|
532
|
+
onClick={handleInputClick}
|
|
533
|
+
>
|
|
534
|
+
{multiple ? (
|
|
535
|
+
// 多选模式:显示标签
|
|
536
|
+
<>
|
|
537
|
+
{selectedItems.map(item => (
|
|
538
|
+
<span key={item.value} className="sd-cascader__tag">
|
|
539
|
+
<span className="sd-cascader__tag-content">{item.label}</span>
|
|
540
|
+
<span
|
|
541
|
+
className="sd-cascader__tag-close"
|
|
542
|
+
onClick={(e) => handleTagClose(item.value, e)}
|
|
543
|
+
>
|
|
544
|
+
×
|
|
545
|
+
</span>
|
|
546
|
+
</span>
|
|
547
|
+
))}
|
|
548
|
+
{selectedItems.length === 0 && (
|
|
549
|
+
<span className="sd-cascader__placeholder">{placeholder}</span>
|
|
550
|
+
)}
|
|
551
|
+
</>
|
|
552
|
+
) : (
|
|
553
|
+
// 单选模式:显示文本
|
|
554
|
+
displayText ? (
|
|
555
|
+
<span className="sd-cascader__selected-text">{displayText}</span>
|
|
556
|
+
) : (
|
|
557
|
+
<span className="sd-cascader__placeholder">{placeholder}</span>
|
|
558
|
+
)
|
|
559
|
+
)}
|
|
560
|
+
|
|
561
|
+
<div className="sd-cascader__suffix">
|
|
562
|
+
{clearable && valueArray.length > 0 && !disabled && (
|
|
563
|
+
<span className="sd-cascader__clear" onClick={handleClear}>
|
|
564
|
+
×
|
|
565
|
+
</span>
|
|
566
|
+
)}
|
|
567
|
+
<span className={classnames('sd-cascader__arrow', { 'is-expanded': isOpen })}>
|
|
568
|
+
⌄
|
|
569
|
+
</span>
|
|
570
|
+
</div>
|
|
571
|
+
</div>
|
|
572
|
+
|
|
573
|
+
{/* 下拉面板 */}
|
|
574
|
+
{isOpen && (
|
|
575
|
+
<div
|
|
576
|
+
className={classnames('sd-cascader__panel', 'is-visible')}
|
|
577
|
+
style={{
|
|
578
|
+
position: 'absolute',
|
|
579
|
+
top: '100%',
|
|
580
|
+
left: 0,
|
|
581
|
+
right: 0,
|
|
582
|
+
zIndex: 9999
|
|
583
|
+
}}
|
|
584
|
+
>
|
|
585
|
+
{/* 搜索框 */}
|
|
586
|
+
{showSearch && (
|
|
587
|
+
<div className="sd-cascader__search">
|
|
588
|
+
<input
|
|
589
|
+
type="text"
|
|
590
|
+
className="sd-cascader__search-input"
|
|
591
|
+
placeholder={searchPlaceholder}
|
|
592
|
+
value={searchValue}
|
|
593
|
+
onChange={(e) => {
|
|
594
|
+
setSearchValue(e.target.value)
|
|
595
|
+
// 重置活动路径和聚焦状态
|
|
596
|
+
setActivePath([])
|
|
597
|
+
setFocusedIndex(-1)
|
|
598
|
+
setFocusedLevel(0)
|
|
599
|
+
}}
|
|
600
|
+
onKeyDown={(e) => {
|
|
601
|
+
// 阻止搜索框的键盘事件冒泡到全局处理
|
|
602
|
+
e.stopPropagation()
|
|
603
|
+
}}
|
|
604
|
+
/>
|
|
605
|
+
</div>
|
|
606
|
+
)}
|
|
607
|
+
|
|
608
|
+
{/* 在搜索模式下,只显示第一级过滤结果 */}
|
|
609
|
+
{!searchValue ? (
|
|
610
|
+
<>
|
|
611
|
+
<div className="sd-cascader__menu">
|
|
612
|
+
{renderMenu(options, [], 0)}
|
|
613
|
+
</div>
|
|
614
|
+
{currentActivePath.map((activeOption, index) => {
|
|
615
|
+
const currentOptions = activeOption.children || []
|
|
616
|
+
const currentPath = currentActivePath.slice(0, index + 1)
|
|
617
|
+
|
|
618
|
+
if (currentOptions.length === 0) return null
|
|
619
|
+
|
|
620
|
+
return (
|
|
621
|
+
<div key={index + 1} className="sd-cascader__menu">
|
|
622
|
+
{renderMenu(currentOptions, currentPath, index + 1)}
|
|
623
|
+
</div>
|
|
624
|
+
)
|
|
625
|
+
})}
|
|
626
|
+
</>
|
|
627
|
+
) : (
|
|
628
|
+
<div className="sd-cascader__menu">
|
|
629
|
+
{renderMenu(getCurrentLevelOptions(0), [], 0)}
|
|
630
|
+
</div>
|
|
631
|
+
)}
|
|
632
|
+
</div>
|
|
633
|
+
)}
|
|
634
|
+
</div>
|
|
635
|
+
)
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
Cascader.displayName = 'Cascader'
|