@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.
Files changed (65) hide show
  1. package/LICENSE +21 -21
  2. package/NOTICE +21 -21
  3. package/README.md +136 -136
  4. package/README.zh_CN.md +136 -136
  5. package/docs/tokens.md +67 -67
  6. package/lib/basic/form.js +49 -49
  7. package/lib/basic/styles.js +391 -391
  8. package/lib/composites/styles.js +427 -427
  9. package/lib/data/styles.js +257 -257
  10. package/lib/display/styles.js +310 -310
  11. package/lib/feedback/styles.js +228 -228
  12. package/lib/foundation/bridge.js +203 -203
  13. package/lib/navigation/styles.js +417 -417
  14. package/package.json +104 -104
  15. package/scripts/check-size.mjs +69 -69
  16. package/scripts/dump-npm-latest.mjs +59 -59
  17. package/scripts/gen-host-tokens.mjs +61 -61
  18. package/scripts/gen-icons.mjs +188 -178
  19. package/scripts/verify-no-literal-colors.mjs +63 -63
  20. package/scripts/verify-tokens.mjs +135 -135
  21. package/src/basic/button.tsx +76 -76
  22. package/src/basic/form.tsx +187 -187
  23. package/src/basic/icon-button.tsx +66 -66
  24. package/src/basic/index.ts +38 -38
  25. package/src/basic/input.tsx +140 -140
  26. package/src/basic/select.tsx +120 -120
  27. package/src/basic/styles.ts +412 -412
  28. package/src/basic/toggle.tsx +116 -116
  29. package/src/composites/data-view.tsx +189 -189
  30. package/src/composites/index.ts +11 -11
  31. package/src/composites/indicator.tsx +90 -90
  32. package/src/composites/menu-select.tsx +230 -230
  33. package/src/composites/panel.tsx +72 -72
  34. package/src/composites/settings.tsx +80 -80
  35. package/src/composites/styles.ts +450 -450
  36. package/src/data/collection.tsx +71 -71
  37. package/src/data/index.ts +39 -39
  38. package/src/data/info.tsx +89 -89
  39. package/src/data/picker.tsx +113 -113
  40. package/src/data/styles.ts +278 -278
  41. package/src/display/container.tsx +67 -67
  42. package/src/display/feedback.tsx +92 -92
  43. package/src/display/index.ts +44 -44
  44. package/src/display/label.tsx +165 -165
  45. package/src/display/overlay.tsx +103 -103
  46. package/src/display/styles.ts +332 -332
  47. package/src/feedback/global.tsx +110 -110
  48. package/src/feedback/index.ts +19 -19
  49. package/src/feedback/inline.tsx +50 -50
  50. package/src/feedback/panel.tsx +93 -93
  51. package/src/feedback/styles.ts +250 -250
  52. package/src/foundation/bridge.ts +318 -318
  53. package/src/foundation/icons.tsx +315 -315
  54. package/src/foundation/index.ts +33 -33
  55. package/src/foundation/palette.ts +153 -153
  56. package/src/foundation/provider.tsx +62 -62
  57. package/src/foundation/style.ts +63 -63
  58. package/src/foundation/tokens.ts +62 -62
  59. package/src/index.ts +18 -18
  60. package/src/navigation/hierarchy.tsx +82 -82
  61. package/src/navigation/index.ts +21 -21
  62. package/src/navigation/indicator.tsx +125 -125
  63. package/src/navigation/menu.tsx +94 -94
  64. package/src/navigation/segmented.tsx +136 -136
  65. 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
+ }