@h-ai/ui 0.1.0-alpha.30 → 0.1.0-alpha.32

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 (36) hide show
  1. package/README.md +38 -3
  2. package/dist/components/compounds/Combobox.svelte +59 -20
  3. package/dist/components/compounds/MessageContainer.svelte +37 -64
  4. package/dist/components/compounds/Modal.svelte +24 -40
  5. package/dist/components/compounds/Pagination.svelte +3 -5
  6. package/dist/components/primitives/Select.svelte +293 -46
  7. package/dist/components/primitives/Textarea.svelte +28 -15
  8. package/dist/components/scenes/ai/AiDocumentEditor.svelte +34 -7
  9. package/dist/components/scenes/ai/MarkdownRenderer.svelte +46 -5
  10. package/dist/components/scenes/ai/document-parse.d.ts +2 -0
  11. package/dist/components/scenes/ai/document-parse.js +16 -17
  12. package/dist/components/scenes/ai/document-types.d.ts +4 -0
  13. package/dist/components/scenes/ai/markdown-parse.d.ts +2 -0
  14. package/dist/components/scenes/ai/markdown-parse.js +17 -20
  15. package/dist/components/scenes/ai/safe-html.d.ts +16 -0
  16. package/dist/components/scenes/ai/safe-html.js +284 -0
  17. package/dist/components/scenes/app/FeedbackModal.svelte +3 -2
  18. package/dist/components/scenes/app/FeedbackModal.svelte.d.ts +1 -0
  19. package/dist/components/scenes/app/SettingsModal.svelte +4 -2
  20. package/dist/components/scenes/app/SettingsModal.svelte.d.ts +1 -0
  21. package/dist/components/scenes/crud/CrudDeleteConfirm.svelte +3 -0
  22. package/dist/components/scenes/crud/CrudDeleteConfirm.svelte.d.ts +1 -0
  23. package/dist/components/scenes/crud/CrudDetailDrawer.svelte +4 -1
  24. package/dist/components/scenes/crud/CrudDetailDrawer.svelte.d.ts +1 -0
  25. package/dist/components/scenes/crud/CrudDetailPanel.svelte +4 -1
  26. package/dist/components/scenes/crud/CrudDetailPanel.svelte.d.ts +1 -0
  27. package/dist/components/scenes/crud/CrudEditPanel.svelte +9 -7
  28. package/dist/components/scenes/crud/CrudEditPanel.svelte.d.ts +1 -0
  29. package/dist/components/scenes/crud/CrudFilterBar.svelte +15 -13
  30. package/dist/components/scenes/crud/CrudFilterBar.svelte.d.ts +1 -0
  31. package/dist/components/scenes/types.d.ts +4 -0
  32. package/dist/styles/global.css +5 -0
  33. package/dist/types.d.ts +5 -5
  34. package/dist/utils.d.ts +13 -0
  35. package/dist/utils.js +25 -1
  36. 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
@@ -36,7 +36,7 @@
36
36
  import type { DataAttributes } from '../../types.js'
37
37
  import { Combobox } from 'bits-ui'
38
38
  import { uiM } from '../../messages.js'
39
- import { getDataAttributes } from '../../utils.js'
39
+ import { cn, getDataAttributes } from '../../utils.js'
40
40
  import BareButton from '../primitives/BareButton.svelte'
41
41
 
42
42
  /** 选项定义 */
@@ -93,8 +93,18 @@
93
93
  value = ''
94
94
  }
95
95
 
96
+ // ─── 统一视觉样式(与 Select 对齐:rounded-lg / 柔和边框 / focus ring) ───
97
+ const controlStateClass = $derived(
98
+ error
99
+ ? 'border-error/60 focus-within:ring-2 focus-within:ring-error/15'
100
+ : 'border-base-content/15 hover:border-base-content/25 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/10',
101
+ )
102
+ const contentClass = 'z-[1200] mt-1.5 max-h-60 w-(--bits-combobox-anchor-width) overflow-y-auto rounded-xl border border-base-content/10 bg-base-100 p-1.5 shadow-xl shadow-base-content/10'
103
+ const itemClass = 'flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2.5 text-sm transition-colors hover:bg-base-content/5 data-highlighted:bg-base-content/5 data-disabled:cursor-not-allowed data-disabled:opacity-40'
104
+
96
105
  let searchValue = $state('')
97
106
  let open = $state(false)
107
+ let inputRef = $state<HTMLInputElement | null>(null)
98
108
 
99
109
  /** 根据搜索关键词过滤选项 */
100
110
  const filteredOptions = $derived(
@@ -131,6 +141,19 @@
131
141
  /** 单选模式:提供给 Combobox.Root 的类型安全值 */
132
142
  const singleVal = $derived(typeof value === 'string' ? value : '')
133
143
 
144
+ function syncInputElementValue() {
145
+ if (!inputRef)
146
+ return
147
+ const nextValue = multiple ? multiInputValue : singleInputValue
148
+ if (inputRef.value !== nextValue) {
149
+ inputRef.value = nextValue
150
+ }
151
+ }
152
+
153
+ $effect(() => {
154
+ syncInputElementValue()
155
+ })
156
+
134
157
  function handleInput(e: Event & { currentTarget: HTMLInputElement }) {
135
158
  searchValue = e.currentTarget.value
136
159
  if (!open)
@@ -145,11 +168,19 @@
145
168
  function handleMultiValueChange(v: string[]) {
146
169
  value = v
147
170
  onchange?.(v)
171
+ setTimeout(() => {
172
+ searchValue = ''
173
+ syncInputElementValue()
174
+ }, 0)
148
175
  }
149
176
 
150
177
  function handleSingleValueChange(v: string) {
151
178
  value = v
152
179
  onchange?.(v)
180
+ setTimeout(() => {
181
+ searchValue = ''
182
+ syncInputElementValue()
183
+ }, 0)
153
184
  }
154
185
 
155
186
  /** 多选模式下移除某个已选项 */
@@ -182,18 +213,20 @@
182
213
  >
183
214
  <div class='relative'>
184
215
  <div
185
- class="min-h-10 px-2 py-1.5 pr-8 border rounded-lg bg-base-100 flex flex-wrap gap-1.5 items-center transition-colors
186
- {disabled ? 'opacity-50 cursor-not-allowed bg-base-200' : 'cursor-text'}
187
- {error ? 'border-error focus-within:border-error' : 'border-base-content/20 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/20'}"
216
+ class={cn(
217
+ 'min-h-10 rounded-lg border bg-base-100 flex flex-wrap gap-1.5 items-center py-1.5 pl-2.5 pr-9 transition-[border-color,box-shadow] duration-150',
218
+ disabled ? 'opacity-50 cursor-not-allowed bg-base-200' : 'cursor-text',
219
+ controlStateClass,
220
+ )}
188
221
  >
189
222
  {#each selectedOptions as opt (opt.value)}
190
- <span class='inline-flex items-center gap-1 px-2 py-0.5 bg-primary/10 text-primary text-sm rounded-md'>
223
+ <span class='inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-0.5 text-sm text-primary'>
191
224
  <span class='truncate max-w-32'>{opt.label}</span>
192
225
  {#if !disabled}
193
226
  <BareButton
194
227
  type='button'
195
- class='hover:bg-primary/20 rounded p-0.5 transition-colors'
196
- onclick={e => removeItem(opt.value, e)}
228
+ class='rounded p-0.5 transition-colors hover:bg-primary/20'
229
+ onclick={(e: MouseEvent) => removeItem(opt.value, e)}
197
230
  ariaLabel="{uiM('combobox_remove')} {opt.label}"
198
231
  >
199
232
  <span class='icon-[tabler--x] size-3'></span>
@@ -203,7 +236,8 @@
203
236
  {/each}
204
237
 
205
238
  <Combobox.Input
206
- class='flex-1 min-w-20 bg-transparent outline-none text-sm py-0.5'
239
+ bind:ref={inputRef}
240
+ class='flex-1 min-w-20 bg-transparent py-0.5 text-sm outline-none placeholder:text-base-content/40'
207
241
  placeholder={selectedOptions.length === 0 ? placeholder : ''}
208
242
  {disabled}
209
243
  oninput={handleInput}
@@ -215,7 +249,7 @@
215
249
  </div>
216
250
 
217
251
  <Combobox.Trigger
218
- class='absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle'
252
+ class='absolute right-2.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center text-base-content/40 transition-colors hover:text-base-content/70'
219
253
  >
220
254
  <span class="icon-[tabler--chevron-down] size-4 transition-transform {open ? 'rotate-180' : ''}"></span>
221
255
  </Combobox.Trigger>
@@ -223,11 +257,11 @@
223
257
 
224
258
  <Combobox.Portal>
225
259
  <Combobox.Content
226
- class='z-50 mt-1 max-h-60 w-[var(--bits-combobox-anchor-width)] overflow-y-auto rounded-lg border border-base-content/10 bg-base-100 p-1 shadow-lg'
260
+ class={contentClass}
227
261
  sideOffset={4}
228
262
  >
229
263
  {#if filteredOptions.length === 0}
230
- <div class='px-3 py-2 text-sm text-base-content/50'>
264
+ <div class='px-3 py-2.5 text-sm text-base-content/50'>
231
265
  {uiM('combobox_no_match')}
232
266
  </div>
233
267
  {:else}
@@ -236,10 +270,10 @@
236
270
  value={opt.value}
237
271
  label={opt.label}
238
272
  disabled={opt.disabled}
239
- class='flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors hover:bg-base-200 data-[highlighted]:bg-base-200 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50'
273
+ class={itemClass}
240
274
  >
241
275
  {#snippet children({ selected })}
242
- <span class="size-4 shrink-0 rounded border border-base-content/30 flex items-center justify-center {selected ? 'bg-primary border-primary' : ''}">
276
+ <span class="flex size-4 shrink-0 items-center justify-center rounded border border-base-content/30 {selected ? 'border-primary bg-primary' : ''}">
243
277
  {#if selected}
244
278
  <span class='icon-[tabler--check] size-3 text-primary-content'></span>
245
279
  {/if}
@@ -247,7 +281,7 @@
247
281
  <div class='flex-1 min-w-0'>
248
282
  <span>{opt.label}</span>
249
283
  {#if opt.description}
250
- <div class='text-xs text-base-content/50 truncate'>{opt.description}</div>
284
+ <div class='truncate text-xs text-base-content/50'>{opt.description}</div>
251
285
  {/if}
252
286
  </div>
253
287
  {/snippet}
@@ -271,7 +305,12 @@
271
305
  >
272
306
  <div class='relative'>
273
307
  <Combobox.Input
274
- class="input w-full pr-8 {error ? 'input-error' : ''}"
308
+ bind:ref={inputRef}
309
+ class={cn(
310
+ 'h-10 w-full rounded-lg border bg-base-100 pl-3 pr-9 text-sm outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-base-content/40',
311
+ controlStateClass,
312
+ disabled && 'opacity-50 cursor-not-allowed',
313
+ )}
275
314
  {placeholder}
276
315
  {disabled}
277
316
  oninput={handleInput}
@@ -281,7 +320,7 @@
281
320
  }}
282
321
  />
283
322
  <Combobox.Trigger
284
- class='absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle'
323
+ class='absolute right-2.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center text-base-content/40 transition-colors hover:text-base-content/70'
285
324
  >
286
325
  <span class="icon-[tabler--chevron-down] size-4 transition-transform {open ? 'rotate-180' : ''}"></span>
287
326
  </Combobox.Trigger>
@@ -289,11 +328,11 @@
289
328
 
290
329
  <Combobox.Portal>
291
330
  <Combobox.Content
292
- class='z-50 mt-1 max-h-60 w-[var(--bits-combobox-anchor-width)] overflow-y-auto rounded-lg border border-base-content/10 bg-base-100 p-1 shadow-lg'
331
+ class={contentClass}
293
332
  sideOffset={4}
294
333
  >
295
334
  {#if filteredOptions.length === 0}
296
- <div class='px-3 py-2 text-sm text-base-content/50'>
335
+ <div class='px-3 py-2.5 text-sm text-base-content/50'>
297
336
  {uiM('combobox_no_match')}
298
337
  </div>
299
338
  {:else}
@@ -302,7 +341,7 @@
302
341
  value={opt.value}
303
342
  label={opt.label}
304
343
  disabled={opt.disabled}
305
- class='flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors hover:bg-base-200 data-[highlighted]:bg-base-200 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50'
344
+ class={cn(itemClass, 'data-selected:bg-primary/10 data-selected:text-primary data-selected:font-medium')}
306
345
  >
307
346
  {#snippet children({ selected })}
308
347
  <span class='size-4 shrink-0'>
@@ -313,7 +352,7 @@
313
352
  <div class='flex-1 min-w-0'>
314
353
  <span>{opt.label}</span>
315
354
  {#if opt.description}
316
- <div class='text-xs text-base-content/50 truncate'>{opt.description}</div>
355
+ <div class='truncate text-xs text-base-content/50'>{opt.description}</div>
317
356
  {/if}
318
357
  </div>
319
358
  {/snippet}
@@ -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
 
@@ -41,6 +41,7 @@
41
41
  const controlSize = $derived(size === 'xs' ? 'xs' : 'sm')
42
42
  const jumperControlSize = 'xs'
43
43
  const sizeChangerClass = $derived(size === 'xs' ? 'w-17 shrink-0' : 'w-18 shrink-0')
44
+ const pageSizeSelectOptions = $derived(pageSizeOptions.map(opt => ({ value: String(opt), label: String(opt) })))
44
45
  const jumperWrapClass = $derived(size === 'xs' ? 'flex min-w-fit items-center gap-1.5 whitespace-nowrap' : 'flex min-w-fit items-center gap-2 whitespace-nowrap')
45
46
  const jumperInputClass = $derived(
46
47
  size === 'xs'
@@ -105,12 +106,9 @@
105
106
  <Select
106
107
  size={controlSize}
107
108
  value={String(pageSize)}
109
+ options={pageSizeSelectOptions}
108
110
  onchange={handlePageSizeChange}
109
- >
110
- {#each pageSizeOptions as opt (opt)}
111
- <option value={String(opt)}>{opt}</option>
112
- {/each}
113
- </Select>
111
+ />
114
112
  </div>
115
113
  </div>
116
114
  {/if}