@dsh-plugin/dsh-ui-components 0.0.1 → 0.1.0-dev.43616582648
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/LICENSE +21 -21
- package/NOTICE +21 -21
- package/README.md +136 -136
- package/README.zh_CN.md +136 -136
- package/docs/tokens.md +67 -67
- package/lib/basic/form.js +49 -49
- package/lib/basic/styles.js +391 -391
- package/lib/composites/styles.js +427 -427
- package/lib/data/styles.js +257 -257
- package/lib/display/styles.js +310 -310
- package/lib/feedback/styles.js +228 -228
- package/lib/foundation/bridge.js +203 -203
- package/lib/navigation/styles.js +417 -417
- package/package.json +104 -104
- package/scripts/check-size.mjs +69 -69
- package/scripts/dump-npm-latest.mjs +59 -59
- package/scripts/gen-host-tokens.mjs +61 -61
- package/scripts/gen-icons.mjs +188 -178
- package/scripts/verify-no-literal-colors.mjs +63 -63
- package/scripts/verify-tokens.mjs +135 -135
- package/src/basic/button.tsx +76 -76
- package/src/basic/form.tsx +187 -187
- package/src/basic/icon-button.tsx +66 -66
- package/src/basic/index.ts +38 -38
- package/src/basic/input.tsx +140 -140
- package/src/basic/select.tsx +120 -120
- package/src/basic/styles.ts +412 -412
- package/src/basic/toggle.tsx +116 -116
- package/src/composites/data-view.tsx +189 -189
- package/src/composites/index.ts +11 -11
- package/src/composites/indicator.tsx +90 -90
- package/src/composites/menu-select.tsx +230 -230
- package/src/composites/panel.tsx +72 -72
- package/src/composites/settings.tsx +80 -80
- package/src/composites/styles.ts +450 -450
- package/src/data/collection.tsx +71 -71
- package/src/data/index.ts +39 -39
- package/src/data/info.tsx +89 -89
- package/src/data/picker.tsx +113 -113
- package/src/data/styles.ts +278 -278
- package/src/display/container.tsx +67 -67
- package/src/display/feedback.tsx +92 -92
- package/src/display/index.ts +44 -44
- package/src/display/label.tsx +165 -165
- package/src/display/overlay.tsx +103 -103
- package/src/display/styles.ts +332 -332
- package/src/feedback/global.tsx +110 -110
- package/src/feedback/index.ts +19 -19
- package/src/feedback/inline.tsx +50 -50
- package/src/feedback/panel.tsx +93 -93
- package/src/feedback/styles.ts +250 -250
- package/src/foundation/bridge.ts +318 -318
- package/src/foundation/icons.tsx +315 -315
- package/src/foundation/index.ts +33 -33
- package/src/foundation/palette.ts +153 -153
- package/src/foundation/provider.tsx +62 -62
- package/src/foundation/style.ts +63 -63
- package/src/foundation/tokens.ts +62 -62
- package/src/index.ts +18 -18
- package/src/navigation/hierarchy.tsx +82 -82
- package/src/navigation/index.ts +21 -21
- package/src/navigation/indicator.tsx +125 -125
- package/src/navigation/menu.tsx +94 -94
- package/src/navigation/segmented.tsx +136 -136
- package/src/navigation/styles.ts +438 -438
|
@@ -1,90 +1,90 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 文件类型徽标与状态行。
|
|
3
|
-
*
|
|
4
|
-
* @module @dsh-plugin/dsh-ui-components/composites/indicator
|
|
5
|
-
*/
|
|
6
|
-
import * as React from 'react'
|
|
7
|
-
import { cx } from '../foundation/style.js'
|
|
8
|
-
import { StateDot, type StateDotState } from '../navigation/indicator.js'
|
|
9
|
-
import { injectCompositeStyles } from './styles.js'
|
|
10
|
-
|
|
11
|
-
/** {@link FileTypeBadge} 的 props。 */
|
|
12
|
-
export interface FileTypeBadgeProps {
|
|
13
|
-
/**
|
|
14
|
-
* 文件名或扩展名。传文件名时取其扩展名;传 `ts` 这类裸扩展名也接受。
|
|
15
|
-
* 没有扩展名时显示 `file`。
|
|
16
|
-
*/
|
|
17
|
-
name: string
|
|
18
|
-
/** 追加类名。 */
|
|
19
|
-
className?: string
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* 从文件名取扩展名。
|
|
24
|
-
*
|
|
25
|
-
* 含点号或路径分隔符时按文件名处理;否则只有足够短的记号才当作裸扩展名
|
|
26
|
-
* (`md`、`tsx`),这样 `Makefile`、`LICENSE` 这类无扩展名文件仍然得到
|
|
27
|
-
* `file`。
|
|
28
|
-
*
|
|
29
|
-
* @param name - 文件名或裸扩展名。
|
|
30
|
-
* @returns 扩展名;无扩展名时返回 `file`。
|
|
31
|
-
*/
|
|
32
|
-
function extensionOf(name: string): string {
|
|
33
|
-
const trimmed = name.trim()
|
|
34
|
-
if (trimmed === '') return 'file'
|
|
35
|
-
if (trimmed.includes('/') || trimmed.includes('\\') || trimmed.includes('.')) {
|
|
36
|
-
const base = trimmed.slice(trimmed.lastIndexOf('/') + 1, undefined)
|
|
37
|
-
const dot = base.lastIndexOf('.')
|
|
38
|
-
if (dot <= 0) return 'file'
|
|
39
|
-
return base.slice(dot + 1)
|
|
40
|
-
}
|
|
41
|
-
return /^[a-z0-9]{1,5}$/i.test(trimmed) ? trimmed : 'file'
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* 文件类型徽标,按扩展名取短标签。
|
|
46
|
-
*
|
|
47
|
-
* @param props - 见 {@link FileTypeBadgeProps}。
|
|
48
|
-
* @returns 徽标元素。
|
|
49
|
-
*/
|
|
50
|
-
export function FileTypeBadge({ name, className }: FileTypeBadgeProps): React.ReactElement {
|
|
51
|
-
injectCompositeStyles()
|
|
52
|
-
const extension = extensionOf(name)
|
|
53
|
-
const label = extension === '' ? 'file' : extension
|
|
54
|
-
return (
|
|
55
|
-
<span className={cx('dsh-file-badge', className)} aria-hidden="true">
|
|
56
|
-
{label}
|
|
57
|
-
</span>
|
|
58
|
-
)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** {@link StatusLine} 的 props。 */
|
|
62
|
-
export interface StatusLineProps {
|
|
63
|
-
/** 状态点。 */
|
|
64
|
-
state?: StateDotState
|
|
65
|
-
/** 状态文字。 */
|
|
66
|
-
children?: React.ReactNode
|
|
67
|
-
/** 右侧的附加信息,通常是用时。 */
|
|
68
|
-
time?: React.ReactNode
|
|
69
|
-
/** 追加类名。 */
|
|
70
|
-
className?: string
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* 状态行:状态点、文字与右侧附加信息。
|
|
75
|
-
*
|
|
76
|
-
* 文字与状态点在同一基线,`time` 用等宽数字右对齐,避免计时变动时整行抖动。
|
|
77
|
-
*
|
|
78
|
-
* @param props - 见 {@link StatusLineProps}。
|
|
79
|
-
* @returns 状态行元素。
|
|
80
|
-
*/
|
|
81
|
-
export function StatusLine({ state = 'ongoing', children, time, className }: StatusLineProps): React.ReactElement {
|
|
82
|
-
injectCompositeStyles()
|
|
83
|
-
return (
|
|
84
|
-
<span className={cx('dsh-status-line', className)}>
|
|
85
|
-
<StateDot state={state} />
|
|
86
|
-
{children === undefined ? null : <span className="dsh-status-line__text">{children}</span>}
|
|
87
|
-
{time === undefined ? null : <span className="dsh-status-line__time">{time}</span>}
|
|
88
|
-
</span>
|
|
89
|
-
)
|
|
90
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 文件类型徽标与状态行。
|
|
3
|
+
*
|
|
4
|
+
* @module @dsh-plugin/dsh-ui-components/composites/indicator
|
|
5
|
+
*/
|
|
6
|
+
import * as React from 'react'
|
|
7
|
+
import { cx } from '../foundation/style.js'
|
|
8
|
+
import { StateDot, type StateDotState } from '../navigation/indicator.js'
|
|
9
|
+
import { injectCompositeStyles } from './styles.js'
|
|
10
|
+
|
|
11
|
+
/** {@link FileTypeBadge} 的 props。 */
|
|
12
|
+
export interface FileTypeBadgeProps {
|
|
13
|
+
/**
|
|
14
|
+
* 文件名或扩展名。传文件名时取其扩展名;传 `ts` 这类裸扩展名也接受。
|
|
15
|
+
* 没有扩展名时显示 `file`。
|
|
16
|
+
*/
|
|
17
|
+
name: string
|
|
18
|
+
/** 追加类名。 */
|
|
19
|
+
className?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 从文件名取扩展名。
|
|
24
|
+
*
|
|
25
|
+
* 含点号或路径分隔符时按文件名处理;否则只有足够短的记号才当作裸扩展名
|
|
26
|
+
* (`md`、`tsx`),这样 `Makefile`、`LICENSE` 这类无扩展名文件仍然得到
|
|
27
|
+
* `file`。
|
|
28
|
+
*
|
|
29
|
+
* @param name - 文件名或裸扩展名。
|
|
30
|
+
* @returns 扩展名;无扩展名时返回 `file`。
|
|
31
|
+
*/
|
|
32
|
+
function extensionOf(name: string): string {
|
|
33
|
+
const trimmed = name.trim()
|
|
34
|
+
if (trimmed === '') return 'file'
|
|
35
|
+
if (trimmed.includes('/') || trimmed.includes('\\') || trimmed.includes('.')) {
|
|
36
|
+
const base = trimmed.slice(trimmed.lastIndexOf('/') + 1, undefined)
|
|
37
|
+
const dot = base.lastIndexOf('.')
|
|
38
|
+
if (dot <= 0) return 'file'
|
|
39
|
+
return base.slice(dot + 1)
|
|
40
|
+
}
|
|
41
|
+
return /^[a-z0-9]{1,5}$/i.test(trimmed) ? trimmed : 'file'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 文件类型徽标,按扩展名取短标签。
|
|
46
|
+
*
|
|
47
|
+
* @param props - 见 {@link FileTypeBadgeProps}。
|
|
48
|
+
* @returns 徽标元素。
|
|
49
|
+
*/
|
|
50
|
+
export function FileTypeBadge({ name, className }: FileTypeBadgeProps): React.ReactElement {
|
|
51
|
+
injectCompositeStyles()
|
|
52
|
+
const extension = extensionOf(name)
|
|
53
|
+
const label = extension === '' ? 'file' : extension
|
|
54
|
+
return (
|
|
55
|
+
<span className={cx('dsh-file-badge', className)} aria-hidden="true">
|
|
56
|
+
{label}
|
|
57
|
+
</span>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** {@link StatusLine} 的 props。 */
|
|
62
|
+
export interface StatusLineProps {
|
|
63
|
+
/** 状态点。 */
|
|
64
|
+
state?: StateDotState
|
|
65
|
+
/** 状态文字。 */
|
|
66
|
+
children?: React.ReactNode
|
|
67
|
+
/** 右侧的附加信息,通常是用时。 */
|
|
68
|
+
time?: React.ReactNode
|
|
69
|
+
/** 追加类名。 */
|
|
70
|
+
className?: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 状态行:状态点、文字与右侧附加信息。
|
|
75
|
+
*
|
|
76
|
+
* 文字与状态点在同一基线,`time` 用等宽数字右对齐,避免计时变动时整行抖动。
|
|
77
|
+
*
|
|
78
|
+
* @param props - 见 {@link StatusLineProps}。
|
|
79
|
+
* @returns 状态行元素。
|
|
80
|
+
*/
|
|
81
|
+
export function StatusLine({ state = 'ongoing', children, time, className }: StatusLineProps): React.ReactElement {
|
|
82
|
+
injectCompositeStyles()
|
|
83
|
+
return (
|
|
84
|
+
<span className={cx('dsh-status-line', className)}>
|
|
85
|
+
<StateDot state={state} />
|
|
86
|
+
{children === undefined ? null : <span className="dsh-status-line__text">{children}</span>}
|
|
87
|
+
{time === undefined ? null : <span className="dsh-status-line__time">{time}</span>}
|
|
88
|
+
</span>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -1,230 +1,230 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 与宿主选择器外观一致的下拉。
|
|
3
|
-
*
|
|
4
|
-
* 原生 `<select>` 的弹层由操作系统绘制,无法跟随 DSH 主题,因此设置页里需要
|
|
5
|
-
* 一个自绘下拉。这个组件用触发器按钮加一个挂在同一 `.dsh-ui` 容器内的浮层实现,
|
|
6
|
-
* 视觉与宿主自己的选择器一致:32px 高的描边触发器、圆角 12 的弹层、菜单材质
|
|
7
|
-
* 与浮起阴影。
|
|
8
|
-
*
|
|
9
|
-
* 键盘行为:↑/↓ 在选项间移动,Home/End 跳到首尾,Enter 选中,Escape 关闭并
|
|
10
|
-
* 把焦点还给触发器。
|
|
11
|
-
*
|
|
12
|
-
* @module @dsh-plugin/dsh-ui-components/composites/menu-select
|
|
13
|
-
*/
|
|
14
|
-
import * as React from 'react'
|
|
15
|
-
import { cx } from '../foundation/style.js'
|
|
16
|
-
import { Icon } from '../foundation/icons.js'
|
|
17
|
-
import { injectCompositeStyles } from './styles.js'
|
|
18
|
-
|
|
19
|
-
/** 一个选项。 */
|
|
20
|
-
export interface MenuSelectOption {
|
|
21
|
-
/** 提交值。 */
|
|
22
|
-
value: string
|
|
23
|
-
/** 展示文本。 */
|
|
24
|
-
label: string
|
|
25
|
-
/** 不可选。 */
|
|
26
|
-
disabled?: boolean
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** {@link MenuSelect} 的 props。 */
|
|
30
|
-
export interface MenuSelectProps {
|
|
31
|
-
/** 选项列表。 */
|
|
32
|
-
options: readonly MenuSelectOption[]
|
|
33
|
-
/** 当前值。 */
|
|
34
|
-
value?: string
|
|
35
|
-
/** 无选中项时显示的占位文本。 */
|
|
36
|
-
placeholder?: string
|
|
37
|
-
/** 选中项变化时回调。 */
|
|
38
|
-
onChange?: (value: string) => void
|
|
39
|
-
/** 禁用。 */
|
|
40
|
-
disabled?: boolean
|
|
41
|
-
/** 输入框 id,用于与外部标签关联。 */
|
|
42
|
-
id?: string
|
|
43
|
-
/** 触发器与弹层的可访问名称。 */
|
|
44
|
-
label?: string
|
|
45
|
-
/** 追加类名。 */
|
|
46
|
-
className?: string
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** 弹层向上展开时的最大高度预算,与样式表里的 `max-height` 一致。 */
|
|
50
|
-
const MAX_POPUP_HEIGHT = 264
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* 宿主风格的下拉选择。
|
|
54
|
-
*
|
|
55
|
-
* @param props - 见 {@link MenuSelectProps}。
|
|
56
|
-
* @returns 带触发器与浮层的下拉元素。
|
|
57
|
-
*/
|
|
58
|
-
export function MenuSelect({
|
|
59
|
-
options,
|
|
60
|
-
value,
|
|
61
|
-
placeholder,
|
|
62
|
-
onChange,
|
|
63
|
-
disabled,
|
|
64
|
-
id,
|
|
65
|
-
label,
|
|
66
|
-
className,
|
|
67
|
-
}: MenuSelectProps): React.ReactElement {
|
|
68
|
-
injectCompositeStyles()
|
|
69
|
-
const [open, setOpen] = React.useState(false)
|
|
70
|
-
const [above, setAbove] = React.useState(false)
|
|
71
|
-
const [activeIndex, setActiveIndex] = React.useState(-1)
|
|
72
|
-
const triggerRef = React.useRef<HTMLButtonElement>(null)
|
|
73
|
-
const wrapperRef = React.useRef<HTMLDivElement>(null)
|
|
74
|
-
|
|
75
|
-
const selectedIndex = options.findIndex(option => option.value === value)
|
|
76
|
-
const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined
|
|
77
|
-
const text = selected === undefined ? placeholder ?? '' : selected.label
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* 根据触发器在窗口中的位置决定弹层向上还是向下展开。
|
|
81
|
-
*/
|
|
82
|
-
const updatePlacement = React.useCallback((): void => {
|
|
83
|
-
const trigger = triggerRef.current
|
|
84
|
-
if (trigger === null) return
|
|
85
|
-
const rect = trigger.getBoundingClientRect()
|
|
86
|
-
const below = window.innerHeight - rect.bottom - 12
|
|
87
|
-
const above = rect.top - 12
|
|
88
|
-
setAbove(below < Math.min(MAX_POPUP_HEIGHT, above))
|
|
89
|
-
}, [])
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* 关闭弹层并把焦点还给触发器。
|
|
93
|
-
*
|
|
94
|
-
* @param refocus - 是否把焦点移回触发器。
|
|
95
|
-
*/
|
|
96
|
-
const close = React.useCallback((refocus: boolean): void => {
|
|
97
|
-
setOpen(false)
|
|
98
|
-
if (refocus) triggerRef.current?.focus()
|
|
99
|
-
}, [])
|
|
100
|
-
|
|
101
|
-
/** 打开时重置高亮项并监听外部点击。 */
|
|
102
|
-
React.useEffect(() => {
|
|
103
|
-
if (!open) return undefined
|
|
104
|
-
updatePlacement()
|
|
105
|
-
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0)
|
|
106
|
-
window.addEventListener('scroll', updatePlacement, true)
|
|
107
|
-
window.addEventListener('resize', updatePlacement)
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* 点击弹层与触发器之外时关闭。
|
|
111
|
-
*
|
|
112
|
-
* @param event - 指针事件。
|
|
113
|
-
*/
|
|
114
|
-
const onPointerDown = (event: PointerEvent): void => {
|
|
115
|
-
const wrapper = wrapperRef.current
|
|
116
|
-
if (wrapper !== null && event.target instanceof Node && !wrapper.contains(event.target)) close(false)
|
|
117
|
-
}
|
|
118
|
-
document.addEventListener('pointerdown', onPointerDown)
|
|
119
|
-
return () => {
|
|
120
|
-
window.removeEventListener('scroll', updatePlacement, true)
|
|
121
|
-
window.removeEventListener('resize', updatePlacement)
|
|
122
|
-
document.removeEventListener('pointerdown', onPointerDown)
|
|
123
|
-
}
|
|
124
|
-
}, [open, selectedIndex, updatePlacement, close])
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* 选中一个选项并关闭。
|
|
128
|
-
*
|
|
129
|
-
* @param option - 被选中的选项。
|
|
130
|
-
*/
|
|
131
|
-
const choose = (option: MenuSelectOption): void => {
|
|
132
|
-
if (option.disabled === true) return
|
|
133
|
-
onChange?.(option.value)
|
|
134
|
-
close(true)
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* 触发器与弹层共用的键盘处理。
|
|
139
|
-
*
|
|
140
|
-
* @param event - 键盘事件。
|
|
141
|
-
*/
|
|
142
|
-
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
|
143
|
-
if (disabled === true) return
|
|
144
|
-
if (!open) {
|
|
145
|
-
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter' || event.key === ' ') {
|
|
146
|
-
event.preventDefault()
|
|
147
|
-
setOpen(true)
|
|
148
|
-
}
|
|
149
|
-
return
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const enabled = options.map((option, index) => (option.disabled === true ? -1 : index)).filter(index => index >= 0)
|
|
153
|
-
if (enabled.length === 0) return
|
|
154
|
-
const position = enabled.indexOf(activeIndex)
|
|
155
|
-
|
|
156
|
-
if (event.key === 'Escape') {
|
|
157
|
-
event.preventDefault()
|
|
158
|
-
close(true)
|
|
159
|
-
return
|
|
160
|
-
}
|
|
161
|
-
if (event.key === 'Enter' || event.key === ' ') {
|
|
162
|
-
event.preventDefault()
|
|
163
|
-
const option = options[activeIndex]
|
|
164
|
-
if (option !== undefined) choose(option)
|
|
165
|
-
return
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
let next: number | undefined
|
|
169
|
-
if (event.key === 'ArrowDown') next = enabled[(position + 1) % enabled.length]
|
|
170
|
-
else if (event.key === 'ArrowUp') next = enabled[(position - 1 + enabled.length) % enabled.length]
|
|
171
|
-
else if (event.key === 'Home') next = enabled[0]
|
|
172
|
-
else if (event.key === 'End') next = enabled[enabled.length - 1]
|
|
173
|
-
if (next === undefined) return
|
|
174
|
-
event.preventDefault()
|
|
175
|
-
setActiveIndex(next)
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
return (
|
|
179
|
-
<div ref={wrapperRef} className={cx('dsh-menu-select', className)} onKeyDown={onKeyDown}>
|
|
180
|
-
<button
|
|
181
|
-
ref={triggerRef}
|
|
182
|
-
type="button"
|
|
183
|
-
id={id}
|
|
184
|
-
disabled={disabled === true}
|
|
185
|
-
className="dsh-menu-select__trigger"
|
|
186
|
-
aria-haspopup="listbox"
|
|
187
|
-
aria-expanded={open}
|
|
188
|
-
aria-label={label === undefined ? text : `${label}: ${text}`}
|
|
189
|
-
title={text}
|
|
190
|
-
onClick={() => setOpen(previous => !previous)}
|
|
191
|
-
>
|
|
192
|
-
<span className={cx('dsh-menu-select__text', selected === undefined && 'dsh-menu-select__text--placeholder')}>{text}</span>
|
|
193
|
-
<span className="dsh-menu-select__caret" aria-hidden="true">
|
|
194
|
-
<Icon name="ChevronDown" size={14} />
|
|
195
|
-
</span>
|
|
196
|
-
</button>
|
|
197
|
-
{open ? (
|
|
198
|
-
<div
|
|
199
|
-
role="listbox"
|
|
200
|
-
aria-label={label}
|
|
201
|
-
className={cx('dsh-menu-select__popup', above && 'dsh-menu-select__popup--above')}
|
|
202
|
-
>
|
|
203
|
-
{options.map((option, index) => (
|
|
204
|
-
<button
|
|
205
|
-
key={option.value}
|
|
206
|
-
type="button"
|
|
207
|
-
role="option"
|
|
208
|
-
aria-selected={option.value === value}
|
|
209
|
-
disabled={option.disabled === true}
|
|
210
|
-
className={cx(
|
|
211
|
-
'dsh-menu-select__option',
|
|
212
|
-
option.value === value && 'dsh-menu-select__option--selected',
|
|
213
|
-
index === activeIndex && 'dsh-menu-select__option--active',
|
|
214
|
-
)}
|
|
215
|
-
onMouseEnter={() => setActiveIndex(index)}
|
|
216
|
-
onClick={() => choose(option)}
|
|
217
|
-
>
|
|
218
|
-
{option.label}
|
|
219
|
-
{option.value === value ? (
|
|
220
|
-
<span className="dsh-menu-select__check" aria-hidden="true">
|
|
221
|
-
<Icon name="Check" size={14} />
|
|
222
|
-
</span>
|
|
223
|
-
) : null}
|
|
224
|
-
</button>
|
|
225
|
-
))}
|
|
226
|
-
</div>
|
|
227
|
-
) : null}
|
|
228
|
-
</div>
|
|
229
|
-
)
|
|
230
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 与宿主选择器外观一致的下拉。
|
|
3
|
+
*
|
|
4
|
+
* 原生 `<select>` 的弹层由操作系统绘制,无法跟随 DSH 主题,因此设置页里需要
|
|
5
|
+
* 一个自绘下拉。这个组件用触发器按钮加一个挂在同一 `.dsh-ui` 容器内的浮层实现,
|
|
6
|
+
* 视觉与宿主自己的选择器一致:32px 高的描边触发器、圆角 12 的弹层、菜单材质
|
|
7
|
+
* 与浮起阴影。
|
|
8
|
+
*
|
|
9
|
+
* 键盘行为:↑/↓ 在选项间移动,Home/End 跳到首尾,Enter 选中,Escape 关闭并
|
|
10
|
+
* 把焦点还给触发器。
|
|
11
|
+
*
|
|
12
|
+
* @module @dsh-plugin/dsh-ui-components/composites/menu-select
|
|
13
|
+
*/
|
|
14
|
+
import * as React from 'react'
|
|
15
|
+
import { cx } from '../foundation/style.js'
|
|
16
|
+
import { Icon } from '../foundation/icons.js'
|
|
17
|
+
import { injectCompositeStyles } from './styles.js'
|
|
18
|
+
|
|
19
|
+
/** 一个选项。 */
|
|
20
|
+
export interface MenuSelectOption {
|
|
21
|
+
/** 提交值。 */
|
|
22
|
+
value: string
|
|
23
|
+
/** 展示文本。 */
|
|
24
|
+
label: string
|
|
25
|
+
/** 不可选。 */
|
|
26
|
+
disabled?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** {@link MenuSelect} 的 props。 */
|
|
30
|
+
export interface MenuSelectProps {
|
|
31
|
+
/** 选项列表。 */
|
|
32
|
+
options: readonly MenuSelectOption[]
|
|
33
|
+
/** 当前值。 */
|
|
34
|
+
value?: string
|
|
35
|
+
/** 无选中项时显示的占位文本。 */
|
|
36
|
+
placeholder?: string
|
|
37
|
+
/** 选中项变化时回调。 */
|
|
38
|
+
onChange?: (value: string) => void
|
|
39
|
+
/** 禁用。 */
|
|
40
|
+
disabled?: boolean
|
|
41
|
+
/** 输入框 id,用于与外部标签关联。 */
|
|
42
|
+
id?: string
|
|
43
|
+
/** 触发器与弹层的可访问名称。 */
|
|
44
|
+
label?: string
|
|
45
|
+
/** 追加类名。 */
|
|
46
|
+
className?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 弹层向上展开时的最大高度预算,与样式表里的 `max-height` 一致。 */
|
|
50
|
+
const MAX_POPUP_HEIGHT = 264
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 宿主风格的下拉选择。
|
|
54
|
+
*
|
|
55
|
+
* @param props - 见 {@link MenuSelectProps}。
|
|
56
|
+
* @returns 带触发器与浮层的下拉元素。
|
|
57
|
+
*/
|
|
58
|
+
export function MenuSelect({
|
|
59
|
+
options,
|
|
60
|
+
value,
|
|
61
|
+
placeholder,
|
|
62
|
+
onChange,
|
|
63
|
+
disabled,
|
|
64
|
+
id,
|
|
65
|
+
label,
|
|
66
|
+
className,
|
|
67
|
+
}: MenuSelectProps): React.ReactElement {
|
|
68
|
+
injectCompositeStyles()
|
|
69
|
+
const [open, setOpen] = React.useState(false)
|
|
70
|
+
const [above, setAbove] = React.useState(false)
|
|
71
|
+
const [activeIndex, setActiveIndex] = React.useState(-1)
|
|
72
|
+
const triggerRef = React.useRef<HTMLButtonElement>(null)
|
|
73
|
+
const wrapperRef = React.useRef<HTMLDivElement>(null)
|
|
74
|
+
|
|
75
|
+
const selectedIndex = options.findIndex(option => option.value === value)
|
|
76
|
+
const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined
|
|
77
|
+
const text = selected === undefined ? placeholder ?? '' : selected.label
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 根据触发器在窗口中的位置决定弹层向上还是向下展开。
|
|
81
|
+
*/
|
|
82
|
+
const updatePlacement = React.useCallback((): void => {
|
|
83
|
+
const trigger = triggerRef.current
|
|
84
|
+
if (trigger === null) return
|
|
85
|
+
const rect = trigger.getBoundingClientRect()
|
|
86
|
+
const below = window.innerHeight - rect.bottom - 12
|
|
87
|
+
const above = rect.top - 12
|
|
88
|
+
setAbove(below < Math.min(MAX_POPUP_HEIGHT, above))
|
|
89
|
+
}, [])
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 关闭弹层并把焦点还给触发器。
|
|
93
|
+
*
|
|
94
|
+
* @param refocus - 是否把焦点移回触发器。
|
|
95
|
+
*/
|
|
96
|
+
const close = React.useCallback((refocus: boolean): void => {
|
|
97
|
+
setOpen(false)
|
|
98
|
+
if (refocus) triggerRef.current?.focus()
|
|
99
|
+
}, [])
|
|
100
|
+
|
|
101
|
+
/** 打开时重置高亮项并监听外部点击。 */
|
|
102
|
+
React.useEffect(() => {
|
|
103
|
+
if (!open) return undefined
|
|
104
|
+
updatePlacement()
|
|
105
|
+
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0)
|
|
106
|
+
window.addEventListener('scroll', updatePlacement, true)
|
|
107
|
+
window.addEventListener('resize', updatePlacement)
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 点击弹层与触发器之外时关闭。
|
|
111
|
+
*
|
|
112
|
+
* @param event - 指针事件。
|
|
113
|
+
*/
|
|
114
|
+
const onPointerDown = (event: PointerEvent): void => {
|
|
115
|
+
const wrapper = wrapperRef.current
|
|
116
|
+
if (wrapper !== null && event.target instanceof Node && !wrapper.contains(event.target)) close(false)
|
|
117
|
+
}
|
|
118
|
+
document.addEventListener('pointerdown', onPointerDown)
|
|
119
|
+
return () => {
|
|
120
|
+
window.removeEventListener('scroll', updatePlacement, true)
|
|
121
|
+
window.removeEventListener('resize', updatePlacement)
|
|
122
|
+
document.removeEventListener('pointerdown', onPointerDown)
|
|
123
|
+
}
|
|
124
|
+
}, [open, selectedIndex, updatePlacement, close])
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 选中一个选项并关闭。
|
|
128
|
+
*
|
|
129
|
+
* @param option - 被选中的选项。
|
|
130
|
+
*/
|
|
131
|
+
const choose = (option: MenuSelectOption): void => {
|
|
132
|
+
if (option.disabled === true) return
|
|
133
|
+
onChange?.(option.value)
|
|
134
|
+
close(true)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 触发器与弹层共用的键盘处理。
|
|
139
|
+
*
|
|
140
|
+
* @param event - 键盘事件。
|
|
141
|
+
*/
|
|
142
|
+
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
|
143
|
+
if (disabled === true) return
|
|
144
|
+
if (!open) {
|
|
145
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter' || event.key === ' ') {
|
|
146
|
+
event.preventDefault()
|
|
147
|
+
setOpen(true)
|
|
148
|
+
}
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const enabled = options.map((option, index) => (option.disabled === true ? -1 : index)).filter(index => index >= 0)
|
|
153
|
+
if (enabled.length === 0) return
|
|
154
|
+
const position = enabled.indexOf(activeIndex)
|
|
155
|
+
|
|
156
|
+
if (event.key === 'Escape') {
|
|
157
|
+
event.preventDefault()
|
|
158
|
+
close(true)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
162
|
+
event.preventDefault()
|
|
163
|
+
const option = options[activeIndex]
|
|
164
|
+
if (option !== undefined) choose(option)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let next: number | undefined
|
|
169
|
+
if (event.key === 'ArrowDown') next = enabled[(position + 1) % enabled.length]
|
|
170
|
+
else if (event.key === 'ArrowUp') next = enabled[(position - 1 + enabled.length) % enabled.length]
|
|
171
|
+
else if (event.key === 'Home') next = enabled[0]
|
|
172
|
+
else if (event.key === 'End') next = enabled[enabled.length - 1]
|
|
173
|
+
if (next === undefined) return
|
|
174
|
+
event.preventDefault()
|
|
175
|
+
setActiveIndex(next)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div ref={wrapperRef} className={cx('dsh-menu-select', className)} onKeyDown={onKeyDown}>
|
|
180
|
+
<button
|
|
181
|
+
ref={triggerRef}
|
|
182
|
+
type="button"
|
|
183
|
+
id={id}
|
|
184
|
+
disabled={disabled === true}
|
|
185
|
+
className="dsh-menu-select__trigger"
|
|
186
|
+
aria-haspopup="listbox"
|
|
187
|
+
aria-expanded={open}
|
|
188
|
+
aria-label={label === undefined ? text : `${label}: ${text}`}
|
|
189
|
+
title={text}
|
|
190
|
+
onClick={() => setOpen(previous => !previous)}
|
|
191
|
+
>
|
|
192
|
+
<span className={cx('dsh-menu-select__text', selected === undefined && 'dsh-menu-select__text--placeholder')}>{text}</span>
|
|
193
|
+
<span className="dsh-menu-select__caret" aria-hidden="true">
|
|
194
|
+
<Icon name="ChevronDown" size={14} />
|
|
195
|
+
</span>
|
|
196
|
+
</button>
|
|
197
|
+
{open ? (
|
|
198
|
+
<div
|
|
199
|
+
role="listbox"
|
|
200
|
+
aria-label={label}
|
|
201
|
+
className={cx('dsh-menu-select__popup', above && 'dsh-menu-select__popup--above')}
|
|
202
|
+
>
|
|
203
|
+
{options.map((option, index) => (
|
|
204
|
+
<button
|
|
205
|
+
key={option.value}
|
|
206
|
+
type="button"
|
|
207
|
+
role="option"
|
|
208
|
+
aria-selected={option.value === value}
|
|
209
|
+
disabled={option.disabled === true}
|
|
210
|
+
className={cx(
|
|
211
|
+
'dsh-menu-select__option',
|
|
212
|
+
option.value === value && 'dsh-menu-select__option--selected',
|
|
213
|
+
index === activeIndex && 'dsh-menu-select__option--active',
|
|
214
|
+
)}
|
|
215
|
+
onMouseEnter={() => setActiveIndex(index)}
|
|
216
|
+
onClick={() => choose(option)}
|
|
217
|
+
>
|
|
218
|
+
{option.label}
|
|
219
|
+
{option.value === value ? (
|
|
220
|
+
<span className="dsh-menu-select__check" aria-hidden="true">
|
|
221
|
+
<Icon name="Check" size={14} />
|
|
222
|
+
</span>
|
|
223
|
+
) : null}
|
|
224
|
+
</button>
|
|
225
|
+
))}
|
|
226
|
+
</div>
|
|
227
|
+
) : null}
|
|
228
|
+
</div>
|
|
229
|
+
)
|
|
230
|
+
}
|