@h-ai/ui 0.1.0-alpha.30 → 0.1.0-alpha.31
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/README.md +38 -3
- package/dist/components/compounds/MessageContainer.svelte +37 -64
- package/dist/components/compounds/Modal.svelte +24 -40
- package/dist/components/primitives/Select.svelte +251 -46
- package/dist/components/primitives/Textarea.svelte +28 -15
- package/dist/components/scenes/ai/AiDocumentEditor.svelte +34 -7
- package/dist/components/scenes/ai/MarkdownRenderer.svelte +46 -5
- package/dist/components/scenes/ai/document-parse.d.ts +2 -0
- package/dist/components/scenes/ai/document-parse.js +16 -17
- package/dist/components/scenes/ai/document-types.d.ts +4 -0
- package/dist/components/scenes/ai/markdown-parse.d.ts +2 -0
- package/dist/components/scenes/ai/markdown-parse.js +17 -20
- package/dist/components/scenes/ai/safe-html.d.ts +16 -0
- package/dist/components/scenes/ai/safe-html.js +284 -0
- package/dist/components/scenes/types.d.ts +4 -0
- package/dist/styles/global.css +5 -0
- package/dist/types.d.ts +5 -5
- package/dist/utils.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -135,7 +135,7 @@ components/
|
|
|
135
135
|
| `Input` | 输入框 | `type`, `value`, `size`, `error`, `validationMessage` |
|
|
136
136
|
| `BareInput` | 无样式输入框 | `type`, `class`, `accept`, `multiple` |
|
|
137
137
|
| `Textarea` | 文本域 | `value`, `rows`, `size`, `autoResize`, `error` |
|
|
138
|
-
| `Select` | 下拉选择 | `options`, `value`, `placeholder`, `size`
|
|
138
|
+
| `Select` | 下拉选择 | `options`, `value`, `placeholder`, `size`, `clearable`, `filterable` |
|
|
139
139
|
| `Checkbox` | 复选框 | `checked`, `label`, `size`, `indeterminate` |
|
|
140
140
|
| `Switch` | 开关 | `checked`, `label`, `size` |
|
|
141
141
|
| `Radio` | 单选组 | `options`, `value`, `direction`, `size` |
|
|
@@ -283,14 +283,31 @@ components/
|
|
|
283
283
|
|
|
284
284
|
| 组件 | 描述 | 主要属性 / 能力 |
|
|
285
285
|
| ------------------------ | ---------------- | ---------------- |
|
|
286
|
-
| `MarkdownRenderer` | Markdown 渲染器 | `content`, `showCopyButton`, `enableHighlight` |
|
|
286
|
+
| `MarkdownRenderer` | Markdown 渲染器 | `content`, `fontSize`, `allowHtmlTags`, `showCopyButton`, `enableHighlight` |
|
|
287
287
|
| `AiDocumentDownloadMenu` | 文档下载菜单 | `actions`, `ondownload` |
|
|
288
|
-
| `AiDocumentEditor` | AI 文档编辑器 | `content`, `showRunButton`, `showCodePreviewToggle`, `allowUnsafeCodePreview` |
|
|
288
|
+
| `AiDocumentEditor` | AI 文档编辑器 | `content`, `fontSize`, `allowHtmlTags`, `showRunButton`, `showCodePreviewToggle`, `allowUnsafeCodePreview` |
|
|
289
289
|
| `AiTableEditor` | AI 表格编辑器 | `columns`, `rows`, `editable`, `ondownload` |
|
|
290
290
|
|
|
291
291
|
> `AiDocumentEditor` 默认只允许 Markdown 内置预览。HTML / JS / CSS 等高风险预览需要显式设置 `allowUnsafeCodePreview`,或通过 `oncoderun` 返回受控的预览结果。
|
|
292
292
|
>
|
|
293
293
|
> Mermaid 图表开箱即用:文档(`sourceKind='document'`)里的 ```` ```mermaid ```` 代码块在阅读态自动渲染为图表;code 模式(`sourceKind='code'` + `showCodePreviewToggle`)下可在「代码 / 预览」间切换。Mermaid 以 `securityLevel: 'strict'` 渲染为消毒后的 SVG,无需开启 `allowUnsafeCodePreview`。
|
|
294
|
+
>
|
|
295
|
+
> `MarkdownRenderer` 与 `AiDocumentEditor` 还支持展示层配置:
|
|
296
|
+
> - `fontSize` 支持 `number`(按 px 解释)或任意 CSS 长度字符串(如 `1.125rem`)
|
|
297
|
+
> - `allowHtmlTags` 默认关闭;开启后会按安全白名单解析 `<b>` / `<i>` / `<u>` / `<mark>` 等 HTML 标签,危险标签与属性仍会被消毒
|
|
298
|
+
|
|
299
|
+
```svelte
|
|
300
|
+
<MarkdownRenderer content={markdown} fontSize='1.125rem' allowHtmlTags />
|
|
301
|
+
|
|
302
|
+
<AiDocumentEditor
|
|
303
|
+
title='方案文档'
|
|
304
|
+
content={documentMarkdown}
|
|
305
|
+
fontSize={18}
|
|
306
|
+
allowHtmlTags
|
|
307
|
+
showToolbar
|
|
308
|
+
showOutline={false}
|
|
309
|
+
/>
|
|
310
|
+
```
|
|
294
311
|
|
|
295
312
|
#### CRUD 场景(5 个)
|
|
296
313
|
|
|
@@ -340,6 +357,24 @@ components/
|
|
|
340
357
|
<ToastContainer />
|
|
341
358
|
```
|
|
342
359
|
|
|
360
|
+
### Message 消息提示
|
|
361
|
+
|
|
362
|
+
```svelte
|
|
363
|
+
<script>
|
|
364
|
+
import { message, MessageContainer } from '@h-ai/ui'
|
|
365
|
+
|
|
366
|
+
function showMessage() {
|
|
367
|
+
message.success('操作成功')
|
|
368
|
+
message.error('操作失败')
|
|
369
|
+
message.warning('请注意')
|
|
370
|
+
message.info('提示信息', 5000)
|
|
371
|
+
}
|
|
372
|
+
</script>
|
|
373
|
+
|
|
374
|
+
<button onclick={showMessage}>消息</button>
|
|
375
|
+
<MessageContainer />
|
|
376
|
+
```
|
|
377
|
+
|
|
343
378
|
### 典型 CRUD 页面
|
|
344
379
|
|
|
345
380
|
```svelte
|
|
@@ -5,9 +5,7 @@
|
|
|
5
5
|
轻量消息提示容器,渲染 message store 中的消息条目。
|
|
6
6
|
视觉风格:顶部居中、带类型图标、自动滑入滑出。
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
- 有打开的 modal dialog 时,寄宿到 dialog 内部(top layer);
|
|
10
|
-
- 否则寄宿到 <body> 以 popover 进入 top layer。
|
|
8
|
+
叠加策略:消息层始终挂载到 `document.body`,使用 fixed overlay + 超高 z-index(高于 Modal 的 `--hai-z-modal`),保证不被任何弹层遮挡;空消息时隐藏容器避免拦截交互。
|
|
11
9
|
=============================================================================
|
|
12
10
|
-->
|
|
13
11
|
<script lang='ts'>
|
|
@@ -36,84 +34,61 @@
|
|
|
36
34
|
error: 'text-error',
|
|
37
35
|
}
|
|
38
36
|
|
|
37
|
+
/** 文案颜色映射 */
|
|
38
|
+
const textColorMap: Record<string, string> = {
|
|
39
|
+
info: 'text-info',
|
|
40
|
+
success: 'text-success',
|
|
41
|
+
warning: 'text-warning',
|
|
42
|
+
error: 'text-error',
|
|
43
|
+
}
|
|
44
|
+
|
|
39
45
|
/** 背景/边框映射 */
|
|
40
46
|
const bgMap: Record<string, string> = {
|
|
41
|
-
info: 'border-info/
|
|
42
|
-
success: 'border-success/
|
|
43
|
-
warning: 'border-warning/
|
|
44
|
-
error: 'border-error/
|
|
47
|
+
info: 'border-info/30 bg-info/15',
|
|
48
|
+
success: 'border-success/30 bg-success/15',
|
|
49
|
+
warning: 'border-warning/30 bg-warning/15',
|
|
50
|
+
error: 'border-error/30 bg-error/15',
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
let layerElement: HTMLDivElement | undefined = $state()
|
|
48
|
-
|
|
49
54
|
const hasItems = $derived(message.items.length > 0)
|
|
50
55
|
|
|
51
|
-
/** 找到最顶层处于打开状态的原生 modal dialog。 */
|
|
52
|
-
function findTopModalDialog(): HTMLDialogElement | null {
|
|
53
|
-
if (typeof document === 'undefined')
|
|
54
|
-
return null
|
|
55
|
-
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog')
|
|
56
|
-
let top: HTMLDialogElement | null = null
|
|
57
|
-
for (const dialog of dialogs) {
|
|
58
|
-
if (dialog.open && typeof dialog.matches === 'function' && dialog.matches(':modal')) {
|
|
59
|
-
top = dialog
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return top
|
|
63
|
-
}
|
|
64
|
-
|
|
65
56
|
function safeShowPopover(el: HTMLElement) {
|
|
66
57
|
try {
|
|
67
|
-
if (typeof el.showPopover === 'function' && el.isConnected && !el.matches(':popover-open'))
|
|
58
|
+
if (typeof el.showPopover === 'function' && el.isConnected && !el.matches(':popover-open'))
|
|
68
59
|
el.showPopover()
|
|
69
|
-
}
|
|
70
60
|
}
|
|
71
|
-
catch {
|
|
61
|
+
catch {
|
|
62
|
+
// 元素未连接或重复调用时忽略。
|
|
63
|
+
}
|
|
72
64
|
}
|
|
73
65
|
|
|
74
66
|
function safeHidePopover(el: HTMLElement) {
|
|
75
67
|
try {
|
|
76
|
-
if (typeof el.hidePopover === 'function' && el.matches(':popover-open'))
|
|
68
|
+
if (typeof el.hidePopover === 'function' && el.matches(':popover-open'))
|
|
77
69
|
el.hidePopover()
|
|
78
|
-
}
|
|
79
70
|
}
|
|
80
|
-
catch {
|
|
71
|
+
catch {
|
|
72
|
+
// 同上,忽略异常。
|
|
73
|
+
}
|
|
81
74
|
}
|
|
82
75
|
|
|
83
|
-
|
|
76
|
+
$effect(() => {
|
|
84
77
|
const el = layerElement
|
|
85
78
|
if (!el)
|
|
86
79
|
return
|
|
87
|
-
|
|
88
|
-
const modal = hasItems ? findTopModalDialog() : null
|
|
89
|
-
|
|
90
|
-
if (modal) {
|
|
91
|
-
safeHidePopover(el)
|
|
92
|
-
if (el.hasAttribute('popover'))
|
|
93
|
-
el.removeAttribute('popover')
|
|
94
|
-
if (el.parentElement !== modal)
|
|
95
|
-
modal.appendChild(el)
|
|
96
|
-
return
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (el.parentElement !== document.body)
|
|
100
|
-
document.body.appendChild(el)
|
|
101
|
-
if (!el.hasAttribute('popover'))
|
|
102
|
-
el.setAttribute('popover', 'manual')
|
|
103
|
-
if (hasItems) {
|
|
80
|
+
if (hasItems)
|
|
104
81
|
safeShowPopover(el)
|
|
105
|
-
|
|
106
|
-
else {
|
|
82
|
+
else
|
|
107
83
|
safeHidePopover(el)
|
|
108
|
-
|
|
109
|
-
}
|
|
84
|
+
})
|
|
110
85
|
|
|
111
86
|
onMount(() => {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
87
|
+
const el = layerElement
|
|
88
|
+
if (el && document.body && el.parentElement !== document.body)
|
|
89
|
+
document.body.appendChild(el)
|
|
90
|
+
|
|
115
91
|
return () => {
|
|
116
|
-
observer.disconnect()
|
|
117
92
|
const el = layerElement
|
|
118
93
|
if (el) {
|
|
119
94
|
safeHidePopover(el)
|
|
@@ -121,11 +96,6 @@
|
|
|
121
96
|
}
|
|
122
97
|
}
|
|
123
98
|
})
|
|
124
|
-
|
|
125
|
-
$effect(() => {
|
|
126
|
-
void hasItems
|
|
127
|
-
relocate()
|
|
128
|
-
})
|
|
129
99
|
</script>
|
|
130
100
|
|
|
131
101
|
<div {...dataAttributes} bind:this={layerElement} popover='manual' class='hai-message-layer'>
|
|
@@ -135,6 +105,7 @@
|
|
|
135
105
|
class={cn(
|
|
136
106
|
'hai-message-item alert shadow-lg border',
|
|
137
107
|
bgMap[item.type] ?? bgMap.info,
|
|
108
|
+
textColorMap[item.type] ?? textColorMap.info,
|
|
138
109
|
)}
|
|
139
110
|
role='alert'
|
|
140
111
|
>
|
|
@@ -161,22 +132,24 @@
|
|
|
161
132
|
inset: 0;
|
|
162
133
|
width: 100%;
|
|
163
134
|
height: 100%;
|
|
164
|
-
max-width: none;
|
|
165
|
-
max-height: none;
|
|
166
135
|
margin: 0;
|
|
167
136
|
padding: 0;
|
|
168
137
|
border: 0;
|
|
169
138
|
background: transparent;
|
|
170
139
|
overflow: visible;
|
|
171
140
|
pointer-events: none;
|
|
172
|
-
z-index:
|
|
141
|
+
z-index: 2147483647;
|
|
173
142
|
}
|
|
174
143
|
|
|
175
144
|
.hai-message-stack {
|
|
176
145
|
position: absolute;
|
|
177
146
|
top: 1rem;
|
|
178
|
-
left:
|
|
179
|
-
|
|
147
|
+
left: 0;
|
|
148
|
+
right: 0;
|
|
149
|
+
margin-left: auto;
|
|
150
|
+
margin-right: auto;
|
|
151
|
+
width: max-content;
|
|
152
|
+
max-width: 100%;
|
|
180
153
|
display: flex;
|
|
181
154
|
flex-direction: column;
|
|
182
155
|
align-items: center;
|
|
@@ -28,9 +28,8 @@
|
|
|
28
28
|
}: ModalProps & DataAttributes = $props()
|
|
29
29
|
|
|
30
30
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
31
|
-
// modalElement
|
|
32
|
-
|
|
33
|
-
let modalElement: HTMLDialogElement | undefined = $state()
|
|
31
|
+
// modalElement 持有 overlay 容器引用;不再依赖原生 `<dialog>`,改用 fixed + z-index 叠加。
|
|
32
|
+
let modalElement: HTMLDivElement | undefined = $state()
|
|
34
33
|
|
|
35
34
|
// 标题区仅在显式提供标题或自定义 header 时出现;
|
|
36
35
|
// 仅需关闭按钮的场景改为右上角悬浮按钮,避免业务侧额外占一整行头部。
|
|
@@ -111,37 +110,17 @@
|
|
|
111
110
|
}
|
|
112
111
|
}
|
|
113
112
|
|
|
114
|
-
function handleDialogCancel(event: Event) {
|
|
115
|
-
// 原生 dialog 在按下 Escape 时会先触发 cancel;
|
|
116
|
-
// 这里统一接管关闭逻辑,避免浏览器直接 close 后跳过组件自己的 onclose 回调。
|
|
117
|
-
event.preventDefault()
|
|
118
|
-
if (closeOnEscape) {
|
|
119
|
-
handleClose()
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
$effect(() => {
|
|
124
|
-
if (!modalElement) {
|
|
125
|
-
return
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (open) {
|
|
129
|
-
if (!modalElement.open) {
|
|
130
|
-
modalElement.showModal()
|
|
131
|
-
}
|
|
132
|
-
return
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (modalElement.open) {
|
|
136
|
-
modalElement.close()
|
|
137
|
-
}
|
|
138
|
-
})
|
|
139
113
|
</script>
|
|
140
114
|
|
|
141
|
-
<
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
115
|
+
<div {...dataAttributes}
|
|
116
|
+
bind:this={modalElement}
|
|
117
|
+
class='hai-modal'
|
|
118
|
+
style:display={open ? '' : 'none'}
|
|
119
|
+
aria-hidden={open ? undefined : 'true'}
|
|
120
|
+
onkeydown={(e) => {
|
|
121
|
+
if (closeOnEscape && e.key === 'Escape')
|
|
122
|
+
handleClose()
|
|
123
|
+
}}
|
|
145
124
|
>
|
|
146
125
|
<div
|
|
147
126
|
class='hai-modal__viewport'
|
|
@@ -223,11 +202,10 @@
|
|
|
223
202
|
{/if}
|
|
224
203
|
</div>
|
|
225
204
|
</div>
|
|
226
|
-
</
|
|
205
|
+
</div>
|
|
227
206
|
|
|
228
207
|
<style>
|
|
229
|
-
|
|
230
|
-
dialog.hai-modal:modal {
|
|
208
|
+
.hai-modal {
|
|
231
209
|
margin: 0;
|
|
232
210
|
padding: 0;
|
|
233
211
|
border: 0;
|
|
@@ -237,17 +215,23 @@
|
|
|
237
215
|
box-sizing: border-box;
|
|
238
216
|
}
|
|
239
217
|
|
|
218
|
+
.hai-modal {
|
|
219
|
+
position: fixed;
|
|
220
|
+
inset: 0;
|
|
221
|
+
z-index: var(--hai-z-modal, 1100);
|
|
222
|
+
display: block;
|
|
223
|
+
pointer-events: none;
|
|
224
|
+
}
|
|
225
|
+
|
|
240
226
|
.hai-modal__viewport {
|
|
241
227
|
position: fixed;
|
|
242
228
|
inset: 0;
|
|
243
229
|
display: grid;
|
|
244
230
|
place-items: center;
|
|
245
231
|
padding: clamp(1rem, 2.6vw, 2rem);
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
dialog.hai-modal::backdrop {
|
|
232
|
+
pointer-events: auto;
|
|
249
233
|
background: rgb(15 23 42 / 0.26);
|
|
250
|
-
background: color-mix(in srgb, var(--color-base-content)
|
|
234
|
+
background: color-mix(in srgb, var(--color-base-content) 15%, transparent);
|
|
251
235
|
backdrop-filter: blur(2px);
|
|
252
236
|
}
|
|
253
237
|
|
|
@@ -373,7 +357,7 @@
|
|
|
373
357
|
}
|
|
374
358
|
|
|
375
359
|
@media (max-width: 768px) {
|
|
376
|
-
|
|
360
|
+
.hai-modal {
|
|
377
361
|
padding: 0.85rem;
|
|
378
362
|
}
|
|
379
363
|
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
=============================================================================
|
|
3
3
|
@h-ai/ui - Select 组件
|
|
4
4
|
=============================================================================
|
|
5
|
-
|
|
5
|
+
下拉选择框组件(全自定义实现,统一弹出层样式)
|
|
6
6
|
|
|
7
7
|
使用 Svelte 5 Runes ($props, $derived, $bindable)
|
|
8
|
-
|
|
8
|
+
支持 clearable(可清空)和 filterable(可筛选)属性
|
|
9
9
|
=============================================================================
|
|
10
10
|
-->
|
|
11
11
|
<script lang='ts' generics="T = string">
|
|
12
12
|
import type { DataAttributes, SelectProps } from '../../types.js'
|
|
13
|
-
import { cn, getDataAttributes
|
|
13
|
+
import { cn, getDataAttributes } from '../../utils.js'
|
|
14
14
|
|
|
15
15
|
let {
|
|
16
16
|
value = $bindable<T>(),
|
|
@@ -19,78 +19,283 @@
|
|
|
19
19
|
size = 'md',
|
|
20
20
|
disabled = false,
|
|
21
21
|
required = false,
|
|
22
|
+
clearable = false,
|
|
23
|
+
filterable = false,
|
|
22
24
|
error = '',
|
|
23
|
-
validationMessage = '',
|
|
24
25
|
class: className = '',
|
|
25
26
|
id,
|
|
26
27
|
onchange,
|
|
27
|
-
children,
|
|
28
28
|
...restProps
|
|
29
29
|
}: SelectProps<T> & DataAttributes = $props()
|
|
30
30
|
|
|
31
31
|
const dataAttributes = $derived(getDataAttributes(restProps))
|
|
32
|
-
let
|
|
32
|
+
let inputRef: HTMLInputElement | undefined = $state()
|
|
33
|
+
let dropdownRef: HTMLDivElement | undefined = $state()
|
|
34
|
+
let isDropdownOpen = $state(false)
|
|
35
|
+
let filterText = $state('')
|
|
36
|
+
let isHovered = $state(false)
|
|
33
37
|
|
|
34
|
-
|
|
38
|
+
// 尺寸映射
|
|
39
|
+
const sizeClasses: Record<string, string> = {
|
|
40
|
+
xs: 'h-7 text-xs px-2',
|
|
41
|
+
sm: 'h-9 text-sm px-3',
|
|
42
|
+
md: 'h-10 text-sm px-3',
|
|
43
|
+
lg: 'h-12 text-base px-4',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 选项文字尺寸
|
|
47
|
+
const optionTextClass = $derived(
|
|
48
|
+
size === 'xs'
|
|
49
|
+
? 'text-xs'
|
|
50
|
+
: size === 'lg'
|
|
51
|
+
? 'text-base'
|
|
52
|
+
: 'text-sm',
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
// 获取当前选中的标签
|
|
56
|
+
const selectedLabel = $derived(() => {
|
|
57
|
+
if (!options || !value)
|
|
58
|
+
return ''
|
|
59
|
+
const selected = options.find(opt => opt.value === value)
|
|
60
|
+
return selected ? selected.label : ''
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// 过滤后的选项
|
|
64
|
+
const filteredOptions = $derived(() => {
|
|
65
|
+
if (!options)
|
|
66
|
+
return []
|
|
67
|
+
if (!filterable || !filterText)
|
|
68
|
+
return options
|
|
69
|
+
return options.filter(opt =>
|
|
70
|
+
opt.label.toLowerCase().includes(filterText.toLowerCase()),
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const wrapperClass = $derived(
|
|
35
75
|
cn(
|
|
36
|
-
'
|
|
37
|
-
|
|
38
|
-
error
|
|
76
|
+
'relative flex w-full items-center rounded-lg border bg-base-100 cursor-pointer',
|
|
77
|
+
sizeClasses[size] || sizeClasses.md,
|
|
78
|
+
error
|
|
79
|
+
? 'border-error/60 focus-within:ring-2 focus-within:ring-error/15'
|
|
80
|
+
: isDropdownOpen
|
|
81
|
+
? 'border-primary/50 ring-2 ring-primary/10'
|
|
82
|
+
: 'border-base-content/15 hover:border-base-content/25 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/10',
|
|
83
|
+
'transition-[border-color,box-shadow] duration-150',
|
|
84
|
+
disabled && 'opacity-50 cursor-not-allowed',
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const inputClass = $derived(
|
|
89
|
+
cn(
|
|
90
|
+
'flex-1 min-w-0 bg-transparent outline-none border-none',
|
|
91
|
+
'focus:outline-none focus:ring-0 focus:shadow-none',
|
|
92
|
+
'placeholder:text-base-content/40',
|
|
93
|
+
error && 'text-error',
|
|
39
94
|
className,
|
|
40
95
|
),
|
|
41
96
|
)
|
|
42
97
|
|
|
43
|
-
//
|
|
98
|
+
// 点击外部关闭下拉框
|
|
44
99
|
$effect(() => {
|
|
45
|
-
|
|
46
|
-
|
|
100
|
+
function handleClickOutside(e: MouseEvent) {
|
|
101
|
+
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
|
|
102
|
+
isDropdownOpen = false
|
|
103
|
+
filterText = ''
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (isDropdownOpen) {
|
|
108
|
+
document.addEventListener('click', handleClickOutside)
|
|
109
|
+
return () => document.removeEventListener('click', handleClickOutside)
|
|
47
110
|
}
|
|
48
111
|
})
|
|
49
112
|
|
|
50
|
-
function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (
|
|
55
|
-
|
|
113
|
+
function handleWrapperClick() {
|
|
114
|
+
if (disabled)
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
if (!isDropdownOpen) {
|
|
118
|
+
isDropdownOpen = true
|
|
56
119
|
}
|
|
57
|
-
|
|
120
|
+
|
|
121
|
+
if (filterable) {
|
|
122
|
+
// filterable 模式下点击后始终聚焦输入框,保持下拉可筛状态
|
|
123
|
+
setTimeout(() => inputRef?.focus(), 0)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function handleOptionSelect(optionValue: T, optionDisabled?: boolean) {
|
|
128
|
+
if (optionDisabled)
|
|
129
|
+
return
|
|
130
|
+
value = optionValue
|
|
131
|
+
isDropdownOpen = false
|
|
132
|
+
filterText = ''
|
|
133
|
+
onchange?.(optionValue)
|
|
58
134
|
}
|
|
59
135
|
|
|
60
|
-
function
|
|
61
|
-
|
|
62
|
-
|
|
136
|
+
function handleFilterInput(e: Event & { currentTarget: HTMLInputElement }) {
|
|
137
|
+
filterText = e.currentTarget.value
|
|
138
|
+
if (!isDropdownOpen) {
|
|
139
|
+
isDropdownOpen = true
|
|
63
140
|
}
|
|
64
141
|
}
|
|
142
|
+
|
|
143
|
+
function handleInputKeydown(e: KeyboardEvent) {
|
|
144
|
+
if (e.key === 'Escape') {
|
|
145
|
+
isDropdownOpen = false
|
|
146
|
+
filterText = ''
|
|
147
|
+
inputRef?.blur()
|
|
148
|
+
}
|
|
149
|
+
else if (e.key === 'Enter') {
|
|
150
|
+
// 回车选中第一个匹配项
|
|
151
|
+
const opts = filteredOptions()
|
|
152
|
+
if (opts.length > 0 && !opts[0].disabled) {
|
|
153
|
+
handleOptionSelect(opts[0].value)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else if (e.key === 'Backspace' && filterText === '' && clearable && value) {
|
|
157
|
+
value = undefined as T
|
|
158
|
+
onchange?.(undefined as T)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function handleClear(e: MouseEvent) {
|
|
163
|
+
e.stopPropagation()
|
|
164
|
+
e.preventDefault()
|
|
165
|
+
value = undefined as T
|
|
166
|
+
filterText = ''
|
|
167
|
+
onchange?.(undefined as T)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 判断是否显示清除按钮
|
|
171
|
+
const showClear = $derived(clearable && value !== undefined && value !== null && value !== '' && isHovered)
|
|
65
172
|
</script>
|
|
66
173
|
|
|
67
|
-
<div
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
174
|
+
<div
|
|
175
|
+
{...dataAttributes}
|
|
176
|
+
class='fieldset w-full'
|
|
177
|
+
bind:this={dropdownRef}
|
|
178
|
+
role='combobox'
|
|
179
|
+
aria-expanded={isDropdownOpen}
|
|
180
|
+
aria-haspopup='listbox'
|
|
181
|
+
onmouseenter={() => isHovered = true}
|
|
182
|
+
onmouseleave={() => isHovered = false}
|
|
183
|
+
>
|
|
184
|
+
<div
|
|
185
|
+
class={wrapperClass}
|
|
186
|
+
onclick={handleWrapperClick}
|
|
187
|
+
onkeydown={(e) => {
|
|
188
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
189
|
+
e.preventDefault()
|
|
190
|
+
handleWrapperClick()
|
|
191
|
+
}
|
|
192
|
+
}}
|
|
193
|
+
role='presentation'
|
|
76
194
|
>
|
|
77
|
-
{#if
|
|
78
|
-
|
|
195
|
+
{#if filterable}
|
|
196
|
+
<!-- 可筛选模式:可输入的 input -->
|
|
197
|
+
<input
|
|
198
|
+
bind:this={inputRef}
|
|
199
|
+
{id}
|
|
200
|
+
type='text'
|
|
201
|
+
class={inputClass}
|
|
202
|
+
{disabled}
|
|
203
|
+
{required}
|
|
204
|
+
{placeholder}
|
|
205
|
+
value={isDropdownOpen ? filterText : selectedLabel()}
|
|
206
|
+
oninput={handleFilterInput}
|
|
207
|
+
onkeydown={handleInputKeydown}
|
|
208
|
+
onfocus={() => {
|
|
209
|
+
if (!disabled && !isDropdownOpen)
|
|
210
|
+
isDropdownOpen = true
|
|
211
|
+
}}
|
|
212
|
+
role='combobox'
|
|
213
|
+
aria-autocomplete='list'
|
|
214
|
+
aria-controls='{id}-listbox'
|
|
215
|
+
aria-expanded={isDropdownOpen}
|
|
216
|
+
/>
|
|
217
|
+
{:else}
|
|
218
|
+
<!-- 普通模式:只读 input 显示选中值 -->
|
|
219
|
+
<input
|
|
220
|
+
{id}
|
|
221
|
+
type='text'
|
|
222
|
+
class={inputClass}
|
|
223
|
+
readonly
|
|
224
|
+
{disabled}
|
|
225
|
+
{required}
|
|
226
|
+
{placeholder}
|
|
227
|
+
value={selectedLabel()}
|
|
228
|
+
tabindex='-1'
|
|
229
|
+
/>
|
|
79
230
|
{/if}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
{#
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
231
|
+
|
|
232
|
+
<!-- 右侧图标区域 -->
|
|
233
|
+
<div class='flex items-center pr-2 shrink-0'>
|
|
234
|
+
{#if showClear}
|
|
235
|
+
<button
|
|
236
|
+
type='button'
|
|
237
|
+
class='flex items-center justify-center w-5 h-5 rounded-full hover:bg-base-content/10 transition-colors'
|
|
238
|
+
onclick={handleClear}
|
|
239
|
+
tabindex='-1'
|
|
240
|
+
aria-label='清空选择'
|
|
241
|
+
>
|
|
242
|
+
<svg xmlns='http://www.w3.org/2000/svg' class='h-3.5 w-3.5' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
|
|
243
|
+
<path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M6 18L18 6M6 6l12 12' />
|
|
244
|
+
</svg>
|
|
245
|
+
</button>
|
|
246
|
+
{:else}
|
|
247
|
+
<svg
|
|
248
|
+
class={cn('h-4 w-4 text-base-content/40 transition-transform duration-200', isDropdownOpen && 'rotate-180')}
|
|
249
|
+
xmlns='http://www.w3.org/2000/svg'
|
|
250
|
+
viewBox='0 0 20 20'
|
|
251
|
+
fill='currentColor'
|
|
88
252
|
>
|
|
89
|
-
|
|
90
|
-
</
|
|
91
|
-
{/
|
|
253
|
+
<path fill-rule='evenodd' d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z' clip-rule='evenodd' />
|
|
254
|
+
</svg>
|
|
255
|
+
{/if}
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
<!-- 下拉选项列表 -->
|
|
259
|
+
{#if isDropdownOpen}
|
|
260
|
+
<ul
|
|
261
|
+
id='{id}-listbox'
|
|
262
|
+
class='absolute left-0 z-50 mt-2 w-full max-h-60 overflow-auto rounded-xl border border-base-content/10 bg-base-100 p-1.5 shadow-xl shadow-base-content/10'
|
|
263
|
+
role='listbox'
|
|
264
|
+
style='top: 100%;'
|
|
265
|
+
>
|
|
266
|
+
{#each filteredOptions() as option (String(option.value))}
|
|
267
|
+
<li
|
|
268
|
+
class={cn(
|
|
269
|
+
'flex cursor-pointer items-center rounded-lg px-3 py-2.5 transition-all duration-150',
|
|
270
|
+
optionTextClass,
|
|
271
|
+
option.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-base-content/5 active:bg-base-content/8',
|
|
272
|
+
value === option.value && 'bg-primary/10 text-primary font-semibold',
|
|
273
|
+
)}
|
|
274
|
+
role='option'
|
|
275
|
+
aria-selected={value === option.value}
|
|
276
|
+
aria-disabled={option.disabled}
|
|
277
|
+
onclick={(e) => {
|
|
278
|
+
e.stopPropagation()
|
|
279
|
+
handleOptionSelect(option.value, option.disabled)
|
|
280
|
+
}}
|
|
281
|
+
onkeydown={(e) => {
|
|
282
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
283
|
+
e.preventDefault()
|
|
284
|
+
handleOptionSelect(option.value, option.disabled)
|
|
285
|
+
}
|
|
286
|
+
}}
|
|
287
|
+
tabindex={option.disabled ? -1 : 0}
|
|
288
|
+
>
|
|
289
|
+
{option.label}
|
|
290
|
+
</li>
|
|
291
|
+
{:else}
|
|
292
|
+
<li class={cn('px-3 py-3 text-base-content/40 text-center', optionTextClass)}>
|
|
293
|
+
无匹配选项
|
|
294
|
+
</li>
|
|
295
|
+
{/each}
|
|
296
|
+
</ul>
|
|
92
297
|
{/if}
|
|
93
|
-
</
|
|
298
|
+
</div>
|
|
94
299
|
{#if error}
|
|
95
300
|
<span class='fieldset-label text-error'>{error}</span>
|
|
96
301
|
{/if}
|