@h-ai/ui 0.1.0-alpha.11 → 0.1.0-alpha.13

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.
@@ -12,6 +12,7 @@
12
12
  import { cn } from '../../utils.js'
13
13
 
14
14
  const {
15
+ id = '',
15
16
  class: className = '',
16
17
  loading = false,
17
18
  disabled = false,
@@ -86,6 +87,7 @@
86
87
 
87
88
  <form
88
89
  bind:this={formElement}
90
+ id={id || undefined}
89
91
  class={formClass}
90
92
  onsubmit={handleSubmit}
91
93
  onreset={handleReset}
@@ -6,7 +6,6 @@
6
6
  import type { ModalProps } from '../../types.js'
7
7
  import { uiM } from '../../messages.js'
8
8
  import { cn } from '../../utils.js'
9
- import IconButton from '../primitives/IconButton.svelte'
10
9
 
11
10
  let {
12
11
  open = $bindable(false),
@@ -15,6 +14,9 @@
15
14
  closeOnBackdrop = true,
16
15
  closeOnEscape = true,
17
16
  showClose = true,
17
+ width,
18
+ height,
19
+ bodyClass = '',
18
20
  class: className = '',
19
21
  onclose,
20
22
  header,
@@ -22,22 +24,43 @@
22
24
  children,
23
25
  }: ModalProps = $props()
24
26
 
27
+ // modalElement 持有原生 dialog 引用;
28
+ // 只有调用 `showModal()` 进入 top-layer 后,弹框才不会被父级布局和 overflow 裁剪。
29
+ let modalElement: HTMLDialogElement | undefined = $state()
30
+
31
+ // 只要存在标题、自定义 header 或关闭按钮,就渲染顶部栏位,保证弹框结构稳定。
32
+ const hasHeader = $derived(Boolean(header || title || showClose))
25
33
  const sizeMap: Record<string, string> = {
26
- 'xs': 'max-w-xs',
27
- 'sm': 'max-w-sm',
28
- 'md': 'max-w-md',
29
- 'lg': 'max-w-lg',
30
- 'xl': 'max-w-xl',
31
- '2xl': 'max-w-2xl',
32
- '3xl': 'max-w-3xl',
33
- '4xl': 'max-w-4xl',
34
- 'full': 'max-w-full h-full',
34
+ 'xs': 'hai-modal__panel--xs',
35
+ 'sm': 'hai-modal__panel--sm',
36
+ 'md': 'hai-modal__panel--md',
37
+ 'lg': 'hai-modal__panel--lg',
38
+ 'xl': 'hai-modal__panel--xl',
39
+ '2xl': 'hai-modal__panel--2xl',
40
+ '3xl': 'hai-modal__panel--3xl',
41
+ '4xl': 'hai-modal__panel--4xl',
42
+ 'full': 'hai-modal__panel--full',
35
43
  }
36
44
 
45
+ // width / height 允许业务侧按需传入 CSS 值,覆盖尺寸预设。
46
+ const panelStyle = $derived.by(() => {
47
+ const styles: string[] = []
48
+
49
+ if (typeof width === 'string' && width.trim().length > 0) {
50
+ styles.push(`--hai-modal-width: ${width.trim()}`)
51
+ }
52
+
53
+ if (typeof height === 'string' && height.trim().length > 0) {
54
+ styles.push(`--hai-modal-height: ${height.trim()}`)
55
+ }
56
+
57
+ return styles.length > 0 ? styles.join('; ') : undefined
58
+ })
59
+
37
60
  const modalBoxClass = $derived(
38
61
  cn(
39
- 'modal-box border border-base-content/6 shadow-xl',
40
- 'animate-[hai-scale-in_0.2s_cubic-bezier(0.16,1,0.3,1)]',
62
+ 'hai-modal__panel flex min-h-0 flex-col overflow-hidden',
63
+ 'bg-base-100 p-0',
41
64
  sizeMap[size],
42
65
  className,
43
66
  ),
@@ -48,76 +71,245 @@
48
71
  onclose?.()
49
72
  }
50
73
 
51
- function handleBackdropClick() {
52
- if (closeOnBackdrop) {
74
+ function handleDialogClick(event: MouseEvent) {
75
+ if (event.target === event.currentTarget && closeOnBackdrop) {
53
76
  handleClose()
54
77
  }
55
78
  }
56
79
 
57
- function handleKeydown(e: KeyboardEvent) {
58
- if (closeOnEscape && e.key === 'Escape') {
80
+ function handleDialogCancel(event: Event) {
81
+ // 原生 dialog 在按下 Escape 时会先触发 cancel;
82
+ // 这里统一接管关闭逻辑,避免浏览器直接 close 后跳过组件自己的 onclose 回调。
83
+ event.preventDefault()
84
+ if (closeOnEscape) {
59
85
  handleClose()
60
86
  }
61
87
  }
62
88
 
63
- // ESC 键处理
64
89
  $effect(() => {
90
+ if (!modalElement) {
91
+ return
92
+ }
93
+
65
94
  if (open) {
66
- document.addEventListener('keydown', handleKeydown)
67
- return () => document.removeEventListener('keydown', handleKeydown)
95
+ if (!modalElement.open) {
96
+ modalElement.showModal()
97
+ }
98
+ return
99
+ }
100
+
101
+ if (modalElement.open) {
102
+ modalElement.close()
68
103
  }
69
- return undefined
70
104
  })
71
105
  </script>
72
106
 
73
- <dialog class='modal backdrop-blur-xs' {open} class:modal-open={open}>
74
- <div class={modalBoxClass}>
75
- <!-- 头部:标题 + 关闭按钮 -->
76
- <div class='flex items-start justify-between gap-4'>
77
- {#if header}
78
- <div class='font-semibold text-base flex-1'>
79
- {@render header()}
80
- </div>
81
- {:else if title}
82
- <h3 class='font-semibold text-base flex-1'>{title}</h3>
83
- {:else}
84
- <div class='flex-1'></div>
85
- {/if}
107
+ <dialog
108
+ bind:this={modalElement}
109
+ class='hai-modal'
110
+ oncancel={handleDialogCancel}
111
+ onclick={handleDialogClick}
112
+ >
113
+ <div class={modalBoxClass} style={panelStyle}>
114
+ {#if hasHeader}
115
+ <!-- 顶部栏单独固定,保证滚动时标题和关闭操作始终可见。 -->
116
+ <div class='hai-modal__header flex flex-none items-start justify-between gap-4 px-6 py-5 sm:px-7'>
117
+ {#if header}
118
+ <div class='flex-1 font-semibold text-[1.05rem] text-base-content/92'>
119
+ {@render header()}
120
+ </div>
121
+ {:else if title}
122
+ <h3 class='flex-1 font-semibold text-[1.05rem] tracking-tight text-base-content/92'>
123
+ {title}
124
+ </h3>
125
+ {:else}
126
+ <div class='flex-1'></div>
127
+ {/if}
86
128
 
87
- {#if showClose}
88
- <IconButton
89
- size='sm'
90
- variant='ghost'
91
- label={uiM('common_close')}
92
- onclick={handleClose}
93
- class='-mt-1 -mr-2'
94
- >
95
- {#snippet children()}
96
- <span class='icon-[tabler--x] size-4.5'></span>
97
- {/snippet}
98
- </IconButton>
99
- {/if}
100
- </div>
129
+ {#if showClose}
130
+ <button
131
+ type='button'
132
+ class='hai-modal__close'
133
+ aria-label={uiM('common_close')}
134
+ onclick={handleClose}
135
+ >
136
+ <svg viewBox='0 0 24 24' class='hai-modal__close-icon' aria-hidden='true'>
137
+ <path
138
+ d='M6 6l12 12M18 6L6 18'
139
+ fill='none'
140
+ stroke='currentColor'
141
+ stroke-width='1.9'
142
+ stroke-linecap='round'
143
+ ></path>
144
+ </svg>
145
+ </button>
146
+ {/if}
147
+ </div>
148
+ {/if}
101
149
 
102
- <div class='py-4'>
150
+ <!-- 主体区域独立滚动,避免长内容把头部和底部一起顶走。 -->
151
+ <div class={cn('min-h-0 flex-1 overflow-y-auto px-6 py-5 sm:px-7', bodyClass)}>
103
152
  {#if children}
104
153
  {@render children()}
105
154
  {/if}
106
155
  </div>
107
156
 
108
157
  {#if footer}
109
- <div class='modal-action'>
110
- {@render footer()}
158
+ <!-- 底栏固定在面板底部,用于承接确认/取消或状态摘要。 -->
159
+ <div class='hai-modal__footer flex flex-none items-center bg-base-100 px-6 py-4 sm:px-7'>
160
+ <div class='flex w-full items-center justify-end gap-3'>
161
+ {@render footer()}
162
+ </div>
111
163
  </div>
112
164
  {/if}
113
165
  </div>
114
-
115
- <button
116
- type='button'
117
- class='modal-backdrop'
118
- aria-label={uiM('modal_close')}
119
- onclick={handleBackdropClick}
120
- >
121
- {uiM('common_close')}
122
- </button>
123
166
  </dialog>
167
+
168
+ <style>
169
+ dialog.hai-modal {
170
+ inset: 0;
171
+ width: 100vw;
172
+ min-width: 0;
173
+ max-width: none;
174
+ height: 100vh;
175
+ height: 100dvh;
176
+ max-height: none;
177
+ margin: 0;
178
+ padding: clamp(1rem, 2.6vw, 2rem);
179
+ border: 0;
180
+ background: transparent;
181
+ outline: none;
182
+ overflow: visible;
183
+ box-sizing: border-box;
184
+ }
185
+
186
+ dialog.hai-modal[open] {
187
+ display: grid;
188
+ place-items: center;
189
+ }
190
+
191
+ dialog.hai-modal::backdrop {
192
+ background: rgb(15 23 42 / 0.26);
193
+ background: color-mix(in srgb, var(--color-base-content) 22%, transparent);
194
+ backdrop-filter: blur(2px);
195
+ }
196
+
197
+ .hai-modal__panel {
198
+ width: var(--hai-modal-width, min(100%, 44rem));
199
+ max-width: min(100%, calc(100vw - clamp(2rem, 5.2vw, 4rem)));
200
+ height: var(--hai-modal-height, auto);
201
+ max-height: calc(100vh - clamp(2rem, 5.2vw, 4rem));
202
+ max-height: calc(100dvh - clamp(2rem, 5.2vw, 4rem));
203
+ border-radius: 1.4rem;
204
+ color: var(--color-base-content);
205
+ outline: none;
206
+ box-shadow:
207
+ 0 34px 84px -42px color-mix(in srgb, var(--color-base-content) 24%, transparent),
208
+ 0 14px 28px -22px color-mix(in srgb, var(--color-primary) 12%, transparent);
209
+ animation: hai-modal-enter 0.22s cubic-bezier(0.16, 1, 0.3, 1);
210
+ }
211
+
212
+ .hai-modal__header {
213
+ box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--color-base-content) 6%, transparent);
214
+ }
215
+
216
+ .hai-modal__footer {
217
+ box-shadow: inset 0 1px 0 color-mix(in srgb, var(--color-base-content) 6%, transparent);
218
+ }
219
+
220
+ .hai-modal__close {
221
+ display: inline-flex;
222
+ width: 2.25rem;
223
+ height: 2.25rem;
224
+ flex: none;
225
+ align-items: center;
226
+ justify-content: center;
227
+ border: 0;
228
+ border-radius: 999px;
229
+ background: color-mix(in srgb, var(--color-base-content) 4%, transparent);
230
+ color: color-mix(in srgb, var(--color-base-content) 54%, transparent);
231
+ cursor: pointer;
232
+ transition:
233
+ background-color 160ms ease,
234
+ color 160ms ease,
235
+ transform 160ms ease;
236
+ }
237
+
238
+ .hai-modal__close:hover {
239
+ background: color-mix(in srgb, var(--color-primary) 10%, transparent);
240
+ color: var(--color-primary);
241
+ transform: translateY(-1px);
242
+ }
243
+
244
+ .hai-modal__close:focus-visible {
245
+ outline: 2px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
246
+ outline-offset: 2px;
247
+ }
248
+
249
+ .hai-modal__close-icon {
250
+ width: 1rem;
251
+ height: 1rem;
252
+ }
253
+
254
+ .hai-modal__panel--xs {
255
+ --hai-modal-width: min(100%, 24rem);
256
+ }
257
+
258
+ .hai-modal__panel--sm {
259
+ --hai-modal-width: min(100%, 32rem);
260
+ }
261
+
262
+ .hai-modal__panel--md {
263
+ --hai-modal-width: min(100%, 40rem);
264
+ }
265
+
266
+ .hai-modal__panel--lg {
267
+ --hai-modal-width: min(100%, 48rem);
268
+ }
269
+
270
+ .hai-modal__panel--xl {
271
+ --hai-modal-width: min(100%, 64rem);
272
+ }
273
+
274
+ .hai-modal__panel--2xl {
275
+ --hai-modal-width: min(100%, 72rem);
276
+ }
277
+
278
+ .hai-modal__panel--3xl {
279
+ --hai-modal-width: min(100%, 80rem);
280
+ }
281
+
282
+ .hai-modal__panel--4xl {
283
+ --hai-modal-width: min(100%, 88rem);
284
+ }
285
+
286
+ .hai-modal__panel--full {
287
+ --hai-modal-width: min(96vw, 96rem);
288
+ --hai-modal-height: min(92vh, 72rem);
289
+ }
290
+
291
+ @keyframes hai-modal-enter {
292
+ from {
293
+ opacity: 0;
294
+ transform: translateY(10px) scale(0.985);
295
+ }
296
+
297
+ to {
298
+ opacity: 1;
299
+ transform: translateY(0) scale(1);
300
+ }
301
+ }
302
+
303
+ @media (max-width: 768px) {
304
+ dialog.hai-modal {
305
+ padding: 0.85rem;
306
+ }
307
+
308
+ .hai-modal__panel {
309
+ max-width: 100%;
310
+ max-height: calc(100vh - 1.7rem);
311
+ max-height: calc(100dvh - 1.7rem);
312
+ border-radius: 1rem;
313
+ }
314
+ }
315
+ </style>
@@ -9,6 +9,7 @@
9
9
  const {
10
10
  variant = 'default',
11
11
  size = 'md',
12
+ form = '',
12
13
  ariaLabel = '',
13
14
  disabled = false,
14
15
  loading = false,
@@ -46,6 +47,7 @@
46
47
 
47
48
  <button
48
49
  {type}
50
+ form={form || undefined}
49
51
  class={buttonClass}
50
52
  disabled={disabled || loading}
51
53
  aria-label={ariaLabel || undefined}
@@ -22,10 +22,15 @@
22
22
 
23
23
  const id = generateId('switch')
24
24
 
25
+ const hasToggleColorVariant = $derived(
26
+ /\btoggle-(?:primary|secondary|accent|neutral|success|warning|info|error)\b/.test(className),
27
+ )
28
+
25
29
  const toggleClass = $derived(
26
30
  cn(
27
31
  'toggle',
28
32
  getSizeClass(size, 'toggle'),
33
+ !hasToggleColorVariant && 'toggle-primary',
29
34
  className,
30
35
  ),
31
36
  )