@h-ai/ui 0.1.0-alpha.31 → 0.1.0-alpha.33

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.
@@ -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}
@@ -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}
@@ -10,7 +10,7 @@
10
10
  -->
11
11
  <script lang='ts' generics="T = string">
12
12
  import type { DataAttributes, SelectProps } from '../../types.js'
13
- import { cn, getDataAttributes } from '../../utils.js'
13
+ import { cn, getDataAttributes, portal } from '../../utils.js'
14
14
 
15
15
  let {
16
16
  value = $bindable<T>(),
@@ -31,16 +31,21 @@
31
31
  const dataAttributes = $derived(getDataAttributes(restProps))
32
32
  let inputRef: HTMLInputElement | undefined = $state()
33
33
  let dropdownRef: HTMLDivElement | undefined = $state()
34
+ let triggerRef: HTMLDivElement | undefined = $state()
35
+ let listboxRef: HTMLUListElement | undefined = $state()
34
36
  let isDropdownOpen = $state(false)
35
37
  let filterText = $state('')
36
38
  let isHovered = $state(false)
39
+ // 下拉层定位样式(portal 到 body,用 fixed 逃逸 overflow/stacking)
40
+ let dropdownStyle = $state('')
37
41
 
38
- // 尺寸映射
42
+ // 尺寸映射(与 Input 保持一致)
39
43
  const sizeClasses: Record<string, string> = {
40
- xs: 'h-7 text-xs px-2',
44
+ xs: 'h-8 text-xs px-2.5',
41
45
  sm: 'h-9 text-sm px-3',
42
46
  md: 'h-10 text-sm px-3',
43
47
  lg: 'h-12 text-base px-4',
48
+ xl: 'h-14 text-lg px-4',
44
49
  }
45
50
 
46
51
  // 选项文字尺寸
@@ -49,6 +54,8 @@
49
54
  ? 'text-xs'
50
55
  : size === 'lg'
51
56
  ? 'text-base'
57
+ : size === 'xl'
58
+ ? 'text-lg'
52
59
  : 'text-sm',
53
60
  )
54
61
 
@@ -73,7 +80,7 @@
73
80
 
74
81
  const wrapperClass = $derived(
75
82
  cn(
76
- 'relative flex w-full items-center rounded-lg border bg-base-100 cursor-pointer',
83
+ 'relative flex w-full min-w-0 items-center rounded-lg border bg-base-100 cursor-pointer',
77
84
  sizeClasses[size] || sizeClasses.md,
78
85
  error
79
86
  ? 'border-error/60 focus-within:ring-2 focus-within:ring-error/15'
@@ -82,6 +89,7 @@
82
89
  : 'border-base-content/15 hover:border-base-content/25 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/10',
83
90
  'transition-[border-color,box-shadow] duration-150',
84
91
  disabled && 'opacity-50 cursor-not-allowed',
92
+ className,
85
93
  ),
86
94
  )
87
95
 
@@ -91,14 +99,16 @@
91
99
  'focus:outline-none focus:ring-0 focus:shadow-none',
92
100
  'placeholder:text-base-content/40',
93
101
  error && 'text-error',
94
- className,
95
102
  ),
96
103
  )
97
104
 
98
- // 点击外部关闭下拉框
105
+ // 点击外部关闭下拉框(下拉层已 portal 到 body,需同时排除 listbox)
99
106
  $effect(() => {
100
107
  function handleClickOutside(e: MouseEvent) {
101
- if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
108
+ const target = e.target as Node
109
+ const inTrigger = dropdownRef?.contains(target) ?? false
110
+ const inListbox = listboxRef?.contains(target) ?? false
111
+ if (!inTrigger && !inListbox) {
102
112
  isDropdownOpen = false
103
113
  filterText = ''
104
114
  }
@@ -110,6 +120,35 @@
110
120
  }
111
121
  })
112
122
 
123
+ // 计算下拉层位置(fixed,基于触发器视口坐标;空间不足时向上翻转)
124
+ function updateDropdownPosition() {
125
+ if (!triggerRef)
126
+ return
127
+ const rect = triggerRef.getBoundingClientRect()
128
+ const gap = 4
129
+ const maxHeight = 240
130
+ const spaceBelow = window.innerHeight - rect.bottom
131
+ const openUp = spaceBelow < maxHeight + gap && rect.top > spaceBelow
132
+ const vertical = openUp
133
+ ? `bottom:${Math.round(window.innerHeight - rect.top + gap)}px`
134
+ : `top:${Math.round(rect.bottom + gap)}px`
135
+ dropdownStyle = `position:fixed;left:${Math.round(rect.left)}px;width:${Math.round(rect.width)}px;${vertical};z-index:1200;`
136
+ }
137
+
138
+ // 打开时定位并跟随滚动/缩放更新
139
+ $effect(() => {
140
+ if (!isDropdownOpen)
141
+ return
142
+ updateDropdownPosition()
143
+ const scrollOpts = { passive: true, capture: true } as const
144
+ window.addEventListener('scroll', updateDropdownPosition, scrollOpts)
145
+ window.addEventListener('resize', updateDropdownPosition)
146
+ return () => {
147
+ window.removeEventListener('scroll', updateDropdownPosition, scrollOpts)
148
+ window.removeEventListener('resize', updateDropdownPosition)
149
+ }
150
+ })
151
+
113
152
  function handleWrapperClick() {
114
153
  if (disabled)
115
154
  return
@@ -173,7 +212,7 @@
173
212
 
174
213
  <div
175
214
  {...dataAttributes}
176
- class='fieldset w-full'
215
+ class='fieldset w-full min-w-0'
177
216
  bind:this={dropdownRef}
178
217
  role='combobox'
179
218
  aria-expanded={isDropdownOpen}
@@ -183,6 +222,7 @@
183
222
  >
184
223
  <div
185
224
  class={wrapperClass}
225
+ bind:this={triggerRef}
186
226
  onclick={handleWrapperClick}
187
227
  onkeydown={(e) => {
188
228
  if (e.key === 'Enter' || e.key === ' ') {
@@ -255,13 +295,15 @@
255
295
  {/if}
256
296
  </div>
257
297
 
258
- <!-- 下拉选项列表 -->
298
+ <!-- 下拉选项列表(portal 到 body,fixed 定位,逃逸 overflow/stacking 裁剪) -->
259
299
  {#if isDropdownOpen}
260
300
  <ul
301
+ bind:this={listboxRef}
302
+ use:portal
261
303
  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'
304
+ class='max-h-60 overflow-auto rounded-xl border border-base-content/10 bg-base-100 p-1.5 shadow-xl shadow-base-content/10'
305
+ style={dropdownStyle}
263
306
  role='listbox'
264
- style='top: 100%;'
265
307
  >
266
308
  {#each filteredOptions() as option (String(option.value))}
267
309
  <li
@@ -55,9 +55,10 @@
55
55
  open?: boolean
56
56
  labels?: FeedbackLabels
57
57
  onsubmit?: (data: FeedbackData) => Promise<void>
58
+ class?: string
58
59
  }
59
60
 
60
- let { open = $bindable(false), labels = {}, onsubmit, ...restProps }: Props & DataAttributes = $props()
61
+ let { open = $bindable(false), labels = {}, onsubmit, class: className = '', ...restProps }: Props & DataAttributes = $props()
61
62
 
62
63
  const dataAttributes = $derived(getDataAttributes(restProps))
63
64
  // 文案优先使用传入的 labels,缺省回退到内置消息 uiM(...)
@@ -109,7 +110,7 @@
109
110
  }
110
111
  </script>
111
112
 
112
- <Modal {...dataAttributes} bind:open title={labels.title ?? uiM('feedback_title')}>
113
+ <Modal {...dataAttributes} class={className} bind:open title={labels.title ?? uiM('feedback_title')}>
113
114
  <p class='text-base-content/70 mb-4'>{labels.description ?? uiM('feedback_description')}</p>
114
115
 
115
116
  {#if error}
@@ -29,6 +29,7 @@ interface Props {
29
29
  open?: boolean;
30
30
  labels?: FeedbackLabels;
31
31
  onsubmit?: (data: FeedbackData) => Promise<void>;
32
+ class?: string;
32
33
  }
33
34
  type $$ComponentProps = Props & DataAttributes;
34
35
  /**
@@ -12,7 +12,7 @@
12
12
  <script lang='ts'>
13
13
  import type { DataAttributes } from '../../../types.js'
14
14
  import { uiM } from '../../../messages.js'
15
- import { getDataAttributes } from '../../../utils.js'
15
+ import { cn, getDataAttributes } from '../../../utils.js'
16
16
  import BareButton from '../../primitives/BareButton.svelte'
17
17
  import IconButton from '../../primitives/IconButton.svelte'
18
18
 
@@ -25,6 +25,7 @@
25
25
  onlanguagechange?: (lang: string) => void
26
26
  onthemechange?: (theme: string) => void
27
27
  onclose?: () => void
28
+ class?: string
28
29
  }
29
30
 
30
31
  let {
@@ -45,6 +46,7 @@
45
46
  onlanguagechange,
46
47
  onthemechange,
47
48
  onclose,
49
+ class: className = '',
48
50
  ...restProps
49
51
  }: Props & DataAttributes = $props()
50
52
 
@@ -76,7 +78,7 @@
76
78
  onclick={handleBackdropClick}
77
79
  >
78
80
  <div
79
- class='relative w-full max-w-lg mx-4 transform overflow-hidden rounded-2xl bg-base-100 shadow-xl transition-all'
81
+ class={cn('relative w-full max-w-lg mx-4 transform overflow-hidden rounded-2xl bg-base-100 shadow-xl transition-all', className)}
80
82
  role='dialog'
81
83
  aria-modal='true'
82
84
  >
@@ -15,6 +15,7 @@ interface Props {
15
15
  onlanguagechange?: (lang: string) => void;
16
16
  onthemechange?: (theme: string) => void;
17
17
  onclose?: () => void;
18
+ class?: string;
18
19
  }
19
20
  type $$ComponentProps = Props & DataAttributes;
20
21
  /**
@@ -18,6 +18,7 @@
18
18
  message = '',
19
19
  onconfirm,
20
20
  oncancel,
21
+ class: className = '',
21
22
  ...restProps
22
23
  }: {
23
24
  open?: boolean
@@ -26,12 +27,14 @@
26
27
  message?: string
27
28
  onconfirm?: () => Promise<void> | void
28
29
  oncancel?: () => void
30
+ class?: string
29
31
  } & DataAttributes = $props()
30
32
 
31
33
  const dataAttributes = $derived(getDataAttributes(restProps))
32
34
  </script>
33
35
 
34
36
  <Confirm {...dataAttributes}
37
+ class={className}
35
38
  bind:open
36
39
  title={title || uiM('crud_delete_title')}
37
40
  message={message || uiM('crud_delete_message')}
@@ -6,6 +6,7 @@ type $$ComponentProps = {
6
6
  message?: string;
7
7
  onconfirm?: () => Promise<void> | void;
8
8
  oncancel?: () => void;
9
+ class?: string;
9
10
  } & DataAttributes;
10
11
  /**
11
12
  * CrudDeleteConfirm
@@ -41,6 +41,7 @@
41
41
  onedit,
42
42
  onclose,
43
43
  detailExtra,
44
+ class: className = '',
44
45
  ...restProps
45
46
  }: {
46
47
  open?: boolean
@@ -57,6 +58,7 @@
57
58
  onedit?: () => void
58
59
  onclose?: () => void
59
60
  detailExtra?: Snippet<[Record<string, unknown>]>
61
+ class?: string
60
62
  } & DataAttributes = $props()
61
63
 
62
64
  const dataAttributes = $derived(getDataAttributes(restProps))
@@ -171,6 +173,7 @@
171
173
  {#if variant === 'modal'}
172
174
  <Modal
173
175
  bind:open
176
+ class={className}
174
177
  {title}
175
178
  size={modalSize}
176
179
  width={modalWidth}
@@ -184,7 +187,7 @@
184
187
  {/snippet}
185
188
  </Modal>
186
189
  {:else}
187
- <Drawer bind:open {title} position='right' {size} width={drawerWidth} onclose={handleClose}>
190
+ <Drawer bind:open {title} class={className} position='right' {size} width={drawerWidth} onclose={handleClose}>
188
191
  {#if item}
189
192
  <div class='pb-20'>
190
193
  {@render detailBody()}
@@ -31,6 +31,7 @@ type $$ComponentProps = {
31
31
  onedit?: () => void;
32
32
  onclose?: () => void;
33
33
  detailExtra?: Snippet<[Record<string, unknown>]>;
34
+ class?: string;
34
35
  } & DataAttributes;
35
36
  /**
36
37
  * CrudDetailDrawer
@@ -42,6 +42,7 @@
42
42
  onedit,
43
43
  onclose,
44
44
  detailExtra,
45
+ class: className = '',
45
46
  ...restProps
46
47
  }: {
47
48
  open?: boolean
@@ -59,6 +60,7 @@
59
60
  onedit?: () => void
60
61
  onclose?: () => void
61
62
  detailExtra?: Snippet<[Record<string, unknown>]>
63
+ class?: string
62
64
  } & DataAttributes = $props()
63
65
 
64
66
  const dataAttributes = $derived(getDataAttributes(restProps))
@@ -179,6 +181,7 @@
179
181
  {#if variant === 'modal'}
180
182
  <Modal
181
183
  bind:open
184
+ class={className}
182
185
  {title}
183
186
  size={modalSize}
184
187
  width={modalWidth}
@@ -192,7 +195,7 @@
192
195
  {/snippet}
193
196
  </Modal>
194
197
  {:else}
195
- <Drawer bind:open {title} position='right' {size} width={drawerWidth} onclose={handleClose}>
198
+ <Drawer bind:open {title} class={className} position='right' {size} width={drawerWidth} onclose={handleClose}>
196
199
  {#if item}
197
200
  <div class={drawerBodyClass}>
198
201
  {@render detailBody()}
@@ -32,6 +32,7 @@ type $$ComponentProps = {
32
32
  onedit?: () => void;
33
33
  onclose?: () => void;
34
34
  detailExtra?: Snippet<[Record<string, unknown>]>;
35
+ class?: string;
35
36
  } & DataAttributes;
36
37
  /**
37
38
  * CrudDetailPanel
@@ -50,6 +50,7 @@
50
50
  onclose,
51
51
  editFormExtra,
52
52
  editingItem = null,
53
+ class: className = '',
53
54
  ...restProps
54
55
  }: {
55
56
  open?: boolean
@@ -70,6 +71,7 @@
70
71
  onclose?: () => void
71
72
  editFormExtra?: Snippet<[Record<string, unknown> | null, 'create' | 'edit']>
72
73
  editingItem?: Record<string, unknown> | null
74
+ class?: string
73
75
  } & DataAttributes = $props()
74
76
 
75
77
  const dataAttributes = $derived(getDataAttributes(restProps))
@@ -171,13 +173,12 @@
171
173
  size={controlSize}
172
174
  value={String(fieldValue ?? '')}
173
175
  disabled={submitting || isReadonly}
176
+ options={[
177
+ { value: '', label: placeholder || uiM('crud_filter_all') },
178
+ ...opts.map(opt => ({ value: String(opt.value), label: opt.label })),
179
+ ]}
174
180
  onchange={value => updateField(field.id, value)}
175
- >
176
- <option value="">{placeholder || uiM('crud_filter_all')}</option>
177
- {#each opts as opt (String(opt.value))}
178
- <option value={String(opt.value)}>{opt.label}</option>
179
- {/each}
180
- </Select>
181
+ />
181
182
  </FormField>
182
183
 
183
184
  {:else if field.type === 'multi-select'}
@@ -255,6 +256,7 @@
255
256
  {#if variant === 'modal'}
256
257
  <Modal
257
258
  bind:open
259
+ class={className}
258
260
  {title}
259
261
  size={modalSize}
260
262
  width={modalWidth}
@@ -271,7 +273,7 @@
271
273
  {/snippet}
272
274
  </Modal>
273
275
  {:else}
274
- <Drawer bind:open {title} position='right' {size} width={drawerWidth} onclose={handleClose} closeOnBackdrop={false}>
276
+ <Drawer bind:open {title} class={className} position='right' {size} width={drawerWidth} onclose={handleClose} closeOnBackdrop={false}>
275
277
  <form onsubmit={handleSubmit} class={drawerFormClass}>
276
278
  {@render formFields()}
277
279
  </form>
@@ -42,6 +42,7 @@ type $$ComponentProps = {
42
42
  onclose?: () => void;
43
43
  editFormExtra?: Snippet<[Record<string, unknown> | null, 'create' | 'edit']>;
44
44
  editingItem?: Record<string, unknown> | null;
45
+ class?: string;
45
46
  } & DataAttributes;
46
47
  /**
47
48
  * CrudEditPanel
@@ -7,7 +7,7 @@
7
7
  <script lang='ts'>
8
8
  import type { DataAttributes } from '../../../types.js'
9
9
  import { uiM } from '../../../messages.js'
10
- import { getDataAttributes } from '../../../utils.js'
10
+ import { cn, getDataAttributes } from '../../../utils.js'
11
11
  import BareButton from '../../primitives/BareButton.svelte'
12
12
  import Input from '../../primitives/Input.svelte'
13
13
  import Select from '../../primitives/Select.svelte'
@@ -34,6 +34,7 @@
34
34
  onsearch,
35
35
  onfilterchange,
36
36
  onreset,
37
+ class: className = '',
37
38
  ...restProps
38
39
  }: {
39
40
  searchable?: boolean
@@ -44,6 +45,7 @@
44
45
  onsearch?: (search: string) => void
45
46
  onfilterchange?: (filters: Record<string, unknown>) => void
46
47
  onreset?: () => void
48
+ class?: string
47
49
  } & DataAttributes = $props()
48
50
 
49
51
  const dataAttributes = $derived(getDataAttributes(restProps))
@@ -120,7 +122,7 @@
120
122
  }
121
123
  </script>
122
124
 
123
- <div {...dataAttributes} class='flex flex-wrap items-center gap-2 [&_.fieldset]:m-0 [&_.fieldset]:min-w-0'>
125
+ <div {...dataAttributes} class={cn('flex flex-wrap items-center gap-2 [&_.fieldset]:m-0 [&_.fieldset]:min-w-0', className)}>
124
126
  {#if searchable}
125
127
  <div class='relative w-full sm:w-64 md:w-72'>
126
128
  <span class='icon-[tabler--search] pointer-events-none absolute left-3 top-1/2 z-10 size-4 -translate-y-1/2 text-base-content/35'></span>
@@ -170,12 +172,13 @@
170
172
  size='sm'
171
173
  class='h-8 border-base-content/20 bg-base-100 shadow-none'
172
174
  value={String(filterValues[field.id] ?? '')}
175
+ options={[
176
+ { value: '', label: uiM('crud_filter_all') },
177
+ { value: 'true', label: uiM('crud_filter_yes') },
178
+ { value: 'false', label: uiM('crud_filter_no') },
179
+ ]}
173
180
  onchange={(value: string) => handleFilterChange(field.id, value)}
174
- >
175
- <option value="">{uiM('crud_filter_all')}</option>
176
- <option value='true'>{uiM('crud_filter_yes')}</option>
177
- <option value='false'>{uiM('crud_filter_no')}</option>
178
- </Select>
181
+ />
179
182
  </div>
180
183
  {:else if isDateField(field)}
181
184
  <div class='w-36 shrink-0'>
@@ -207,13 +210,12 @@
207
210
  size='sm'
208
211
  class='h-8 border-base-content/20 bg-base-100 shadow-none'
209
212
  value={String(filterValues[field.id] ?? '')}
213
+ options={[
214
+ { value: '', label: uiM('crud_filter_all') },
215
+ ...opts.map(opt => ({ value: String(opt.value), label: opt.label })),
216
+ ]}
210
217
  onchange={(value: string) => handleFilterChange(field.id, value)}
211
- >
212
- <option value="">{uiM('crud_filter_all')}</option>
213
- {#each opts as opt (String(opt.value))}
214
- <option value={String(opt.value)}>{opt.label}</option>
215
- {/each}
216
- </Select>
218
+ />
217
219
  </div>
218
220
  {:else if isTextField(field)}
219
221
  <div class='w-44 shrink-0'>
@@ -26,6 +26,7 @@ type $$ComponentProps = {
26
26
  onsearch?: (search: string) => void;
27
27
  onfilterchange?: (filters: Record<string, unknown>) => void;
28
28
  onreset?: () => void;
29
+ class?: string;
29
30
  } & DataAttributes;
30
31
  /**
31
32
  * CrudFilterBar
package/dist/utils.d.ts CHANGED
@@ -49,3 +49,16 @@ export declare function getProgressVariantClass(variant: Variant): string;
49
49
  * 生成唯一 ID
50
50
  */
51
51
  export declare function generateId(prefix?: string): string;
52
+ /**
53
+ * Portal action:将节点移动到目标容器(默认 document.body)下渲染。
54
+ *
55
+ * 用于弹出层(下拉、气泡等)逃逸祖先的 `overflow: hidden` 裁剪与
56
+ * 堆叠上下文(stacking context)限制,避免被相邻控件遮挡。
57
+ *
58
+ * @param node - 要移动的节点
59
+ * @param target - 目标容器或选择器;默认 body
60
+ */
61
+ export declare function portal(node: HTMLElement, target?: HTMLElement | string): {
62
+ update(next: HTMLElement | string): void;
63
+ destroy(): void;
64
+ };
package/dist/utils.js CHANGED
@@ -176,3 +176,27 @@ export function getProgressVariantClass(variant) {
176
176
  export function generateId(prefix = 'hai') {
177
177
  return `${prefix}-${Math.random().toString(36).slice(2, 9)}`;
178
178
  }
179
+ /**
180
+ * Portal action:将节点移动到目标容器(默认 document.body)下渲染。
181
+ *
182
+ * 用于弹出层(下拉、气泡等)逃逸祖先的 `overflow: hidden` 裁剪与
183
+ * 堆叠上下文(stacking context)限制,避免被相邻控件遮挡。
184
+ *
185
+ * @param node - 要移动的节点
186
+ * @param target - 目标容器或选择器;默认 body
187
+ */
188
+ export function portal(node, target = 'body') {
189
+ function mount(to) {
190
+ const targetEl = typeof to === 'string' ? document.querySelector(to) : to;
191
+ targetEl?.appendChild(node);
192
+ }
193
+ mount(target);
194
+ return {
195
+ update(next) {
196
+ mount(next);
197
+ },
198
+ destroy() {
199
+ node.parentNode?.removeChild(node);
200
+ },
201
+ };
202
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@h-ai/ui",
3
3
  "type": "module",
4
- "version": "0.1.0-alpha.31",
4
+ "version": "0.1.0-alpha.33",
5
5
  "description": "Hai Framework UI component library for Svelte 5 Runes.",
6
6
  "license": "Apache-2.0",
7
7
  "svelte": "./dist/index.js",
@@ -51,7 +51,7 @@
51
51
  "mermaid": "^11.12.0",
52
52
  "shiki": "^4.0.2",
53
53
  "tailwind-merge": "^3.5.0",
54
- "@h-ai/core": "0.1.0-alpha.31"
54
+ "@h-ai/core": "0.1.0-alpha.33"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@sveltejs/package": "^2.5.7",