@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 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
- 叠加策略与 ToastContainer 一致:
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/20 bg-info/5',
42
- success: 'border-success/20 bg-success/5',
43
- warning: 'border-warning/20 bg-warning/5',
44
- error: 'border-error/20 bg-error/5',
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
- function relocate() {
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
- relocate()
113
- const observer = new MutationObserver(() => relocate())
114
- observer.observe(document.body, { subtree: true, attributes: true, attributeFilter: ['open'] })
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: 9999;
141
+ z-index: 2147483647;
173
142
  }
174
143
 
175
144
  .hai-message-stack {
176
145
  position: absolute;
177
146
  top: 1rem;
178
- left: 50%;
179
- transform: translateX(-50%);
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 持有原生 dialog 引用;
32
- // 只有调用 `showModal()` 进入 top-layer 后,弹框才不会被父级布局和 overflow 裁剪。
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
- <dialog {...dataAttributes}
142
- bind:this={modalElement}
143
- class='hai-modal'
144
- oncancel={handleDialogCancel}
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
- </dialog>
205
+ </div>
227
206
 
228
207
  <style>
229
- dialog.hai-modal,
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) 22%, transparent);
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
- dialog.hai-modal {
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
- 支持自定义验证消息(validationMessage)覆盖浏览器原生提示
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, getSizeClass } from '../../utils.js'
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 selectRef: HTMLSelectElement | undefined = $state()
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
- const selectClass = $derived(
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
- 'select w-full',
37
- getSizeClass(size, 'input'),
38
- error && 'select-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
- // 当 validationMessage 变化时更新自定义验证消息
98
+ // 点击外部关闭下拉框
44
99
  $effect(() => {
45
- if (selectRef) {
46
- selectRef.setCustomValidity(validationMessage)
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 handleChange(e: Event & { currentTarget: HTMLSelectElement }) {
51
- const selectedValue = e.currentTarget.value as T
52
- value = selectedValue
53
- // 选择后重置自定义验证
54
- if (validationMessage) {
55
- e.currentTarget.setCustomValidity(validationMessage)
113
+ function handleWrapperClick() {
114
+ if (disabled)
115
+ return
116
+
117
+ if (!isDropdownOpen) {
118
+ isDropdownOpen = true
56
119
  }
57
- onchange?.(selectedValue)
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 handleInvalid(e: Event & { currentTarget: HTMLSelectElement }) {
61
- if (validationMessage) {
62
- e.currentTarget.setCustomValidity(validationMessage)
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 {...dataAttributes} class='fieldset w-full'>
68
- <select
69
- bind:this={selectRef}
70
- {id}
71
- class={selectClass}
72
- {disabled}
73
- {required}
74
- onchange={handleChange}
75
- oninvalid={handleInvalid}
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 placeholder}
78
- <option value="" disabled selected={!value}>{placeholder}</option>
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
- {#if children}
81
- {@render children()}
82
- {:else if options}
83
- {#each options as option (String(option.value))}
84
- <option
85
- value={option.value as string}
86
- disabled={option.disabled}
87
- selected={value === option.value}
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
- {option.label}
90
- </option>
91
- {/each}
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
- </select>
298
+ </div>
94
299
  {#if error}
95
300
  <span class='fieldset-label text-error'>{error}</span>
96
301
  {/if}