@h-ai/ui 0.1.0-alpha.24 → 0.1.0-alpha.26
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 +41 -11
- package/auto-import.d.ts +5 -0
- package/auto-import.js +4 -0
- package/dist/components/compounds/DataTable.svelte +81 -4
- package/dist/components/compounds/DataTable.svelte.d.ts +7 -0
- package/dist/components/compounds/Modal.svelte +123 -63
- package/dist/components/compounds/Pagination.svelte +72 -119
- package/dist/components/scenes/ai/markdown-parse.js +12 -4
- package/dist/components/scenes/ai/table/AiTableEditor.svelte +4 -2
- package/dist/components/scenes/app/SettingsLayout.svelte +111 -0
- package/dist/components/scenes/app/SettingsLayout.svelte.d.ts +41 -0
- package/dist/components/scenes/app/ThemeColorPicker.svelte +41 -0
- package/dist/components/scenes/app/index.d.ts +1 -0
- package/dist/components/scenes/app/index.js +1 -0
- package/dist/components/scenes/crud/CrudFilterBar.svelte +173 -35
- package/dist/components/scenes/crud/CrudFilterBar.svelte.d.ts +7 -1
- package/dist/components/scenes/crud/CrudPage.svelte +19 -15
- package/dist/components/scenes/error/ErrorPage.svelte +125 -0
- package/dist/components/scenes/error/ErrorPage.svelte.d.ts +42 -0
- package/dist/components/scenes/error/error-presets.d.ts +24 -0
- package/dist/components/scenes/error/error-presets.js +31 -0
- package/dist/components/scenes/error/index.d.ts +8 -0
- package/dist/components/scenes/error/index.js +8 -0
- package/dist/components/scenes/iam/AuthShell.svelte +138 -0
- package/dist/components/scenes/iam/AuthShell.svelte.d.ts +45 -0
- package/dist/components/scenes/iam/index.d.ts +1 -0
- package/dist/components/scenes/iam/index.js +2 -1
- package/dist/components/scenes/index.d.ts +1 -0
- package/dist/components/scenes/index.js +2 -0
- package/dist/messages/en-US.json +20 -1
- package/dist/messages/zh-CN.json +20 -1
- package/dist/styles/global.css +4 -1
- package/dist/theme-config.d.ts +60 -4
- package/dist/theme-config.js +77 -6
- package/dist/types.d.ts +6 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -151,7 +151,7 @@ components/
|
|
|
151
151
|
|
|
152
152
|
| 组件 | 描述 | 主要属性 |
|
|
153
153
|
| --------- | ------ | ------------------------------------------------------- |
|
|
154
|
-
| `Modal` | 模态框 | `open`, `title`, `size`, `
|
|
154
|
+
| `Modal` | 模态框 | `open`, `title`, `size`, `radius`, `bodyOverflow`, `showClose` |
|
|
155
155
|
| `Drawer` | 抽屉 | `open`, `position`, `title`, `size` |
|
|
156
156
|
| `Confirm` | 确认框 | `open`, `title`, `message`, `variant`, `onconfirm` |
|
|
157
157
|
| `Popover` | 弹出层 | `open`, `position`, `trigger`, `offset` |
|
|
@@ -161,7 +161,7 @@ components/
|
|
|
161
161
|
| 组件 | 描述 | 主要属性 |
|
|
162
162
|
| ----------- | -------- | ---------------------------------------- |
|
|
163
163
|
| `Card` | 卡片容器 | `title`, `bordered`, `shadow`, `padding` |
|
|
164
|
-
| `DataTable` | 数据表格 | `data`, `columns`, `keyField`, `loading` |
|
|
164
|
+
| `DataTable` | 数据表格 | `data`, `columns`, `keyField`, `loading`, `sortKey`, `sortDir`, `onsort`(列 `sortable` 可排序) |
|
|
165
165
|
| `Accordion` | 手风琴 | `items: AccordionItem[]` |
|
|
166
166
|
| `Timeline` | 时间线 | `items: TimelineItem[]` |
|
|
167
167
|
|
|
@@ -192,7 +192,7 @@ components/
|
|
|
192
192
|
| 组件 | 描述 | 主要属性 |
|
|
193
193
|
| ------------ | -------- | -------------------------------------------- |
|
|
194
194
|
| `Tabs` | 标签页 | `items`, `active`, `type`, `size` |
|
|
195
|
-
| `Pagination` |
|
|
195
|
+
| `Pagination` | 分页(统一 table 风格) | `page`, `total`, `pageSize`, `showTotal`, `showJumper`, `showSizeChanger` |
|
|
196
196
|
| `Breadcrumb` | 面包屑 | `items`, `separator` |
|
|
197
197
|
| `Steps` | 步骤条 | `items`, `current`, `direction`, `clickable` |
|
|
198
198
|
| `Dropdown` | 下拉菜单 | `items`, `trigger`, `position` |
|
|
@@ -212,23 +212,25 @@ components/
|
|
|
212
212
|
| ------------ | -------- | ------------------------------------------- |
|
|
213
213
|
| `PageHeader` | 页面头部 | `title`, `description`,支持 `actions` 插槽 |
|
|
214
214
|
|
|
215
|
-
### 场景组件 Scenes(
|
|
215
|
+
### 场景组件 Scenes(33 个)
|
|
216
216
|
|
|
217
|
-
#### App 应用级(
|
|
217
|
+
#### App 应用级(7 个)
|
|
218
218
|
|
|
219
219
|
| 组件 | 描述 | 主要属性 |
|
|
220
220
|
| ---------------- | ---------------- | ------------------ |
|
|
221
221
|
| `FeedbackModal` | 反馈模态框 | `open`, `onsubmit` |
|
|
222
222
|
| `SettingsModal` | 设置模态框 | `open`, `onclose` |
|
|
223
|
+
| `SettingsLayout` | 设置页布局(分区导航 + 内容区) | `title`, `description`, `sections`, `active`, `onselect` |
|
|
223
224
|
| `LanguageSwitch` | 语言切换 | 无需 Props |
|
|
224
225
|
| `ThemeColorPicker` | 主题色选择器 | `value`, `presets`, `onchange` |
|
|
225
226
|
| `ThemeSelector` | 完整主题选择面板 | 无需 Props |
|
|
226
227
|
| `ThemeToggle` | 明/暗主题切换 | 无需 Props |
|
|
227
228
|
|
|
228
|
-
#### IAM 身份认证(
|
|
229
|
+
#### IAM 身份认证(9 个)
|
|
229
230
|
|
|
230
231
|
| 组件 | 描述 | 主要属性 |
|
|
231
232
|
| -------------------- | ---------- | ----------------------------------------------------- |
|
|
233
|
+
| `AuthShell` | 认证页布局(居中卡片 / 左右分栏) | `variant`, `title`, `subtitle`, `brandTitle`, `brandText`, `illustration`, `description`, `highlights` |
|
|
232
234
|
| `LoginForm` | 登录表单 | `loading`, `errors`, `showRememberMe`, `onsubmit` |
|
|
233
235
|
| `RegisterForm` | 注册表单 | `loading`, `errors`, `fields`, `onsubmit` |
|
|
234
236
|
| `ForgotPasswordForm` | 忘记密码 | `mode`, `loading`, `errors`, `onsubmit` |
|
|
@@ -275,11 +277,19 @@ components/
|
|
|
275
277
|
| 组件 | 描述 | 主要属性 / 能力 |
|
|
276
278
|
| -------------------- | ------------ | ---------------- |
|
|
277
279
|
| `CrudPage` | CRUD 主页面 | `crud`, `data`, `permissions`, `form`, `pagination`, `density`, `nav` |
|
|
278
|
-
| `CrudFilterBar` |
|
|
280
|
+
| `CrudFilterBar` | 过滤工具栏(搜索 + 多类型过滤 + 重置) | `filterFields`, `searchValue`, `onsearch`, `onfilterchange`, `onreset` |
|
|
279
281
|
| `CrudDetailPanel` | 详情面板(抽屉/弹窗) | `open`, `item`, `fields`, `variant`, `drawerWidth`, `modalSize` |
|
|
280
282
|
| `CrudEditPanel` | 编辑面板(抽屉/弹窗) | `open`, `fields`, `variant`, `drawerWidth`, `modalSize`, `onsubmit` |
|
|
281
283
|
| `CrudDeleteConfirm` | 删除确认框 | `open`, `loading`, `onconfirm` |
|
|
282
284
|
|
|
285
|
+
> 列表列头默认支持点击排序(`DataTable` 内置客户端排序,受控模式可通过 `sortKey`/`sortDir`/`onsort` 接管为服务端排序);存在搜索/过滤条件时筛选栏显示「重置」按钮。过滤工具栏支持常用控件:`select`、`boolean`、`number`、`date`、`date-range`、`text`。分页栏统一为 shadcn 风格(总数 / 每页 / 第 X / Y 页 / 首末翻页 / 跳转到)。
|
|
286
|
+
|
|
287
|
+
#### 错误页场景(1 个)
|
|
288
|
+
|
|
289
|
+
| 组件 | 描述 | 主要属性 / 能力 |
|
|
290
|
+
| ----------- | ------------ | ---------------- |
|
|
291
|
+
| `ErrorPage` | 通用错误页(内置 401/403/404/500/503 预设) | `status`, `code`, `title`, `description`, `homeUrl`, `onhome`, `onback` |
|
|
292
|
+
|
|
283
293
|
> `CrudPage` 通过 `form` 配置新建/编辑的展示形式:
|
|
284
294
|
>
|
|
285
295
|
> - `form.variant`:`'drawer'`(抽屉,默认)或 `'modal'`(弹出窗口)。
|
|
@@ -378,6 +388,8 @@ components/
|
|
|
378
388
|
<ToastContainer />
|
|
379
389
|
```
|
|
380
390
|
|
|
391
|
+
`Modal` 不传 `title` / `header` 时会保留右上角关闭按钮,但不额外渲染整条头部。需要调整圆角时可传 `radius="1.4rem"`,内部下拉等浮层需要越界显示时可传 `bodyOverflow="visible"`。
|
|
392
|
+
|
|
381
393
|
### 声明式 CRUD 页面(CrudPage)
|
|
382
394
|
|
|
383
395
|
```svelte
|
|
@@ -506,24 +518,42 @@ npm install -D @iconify/tailwind4 @iconify-json/tabler
|
|
|
506
518
|
```ts
|
|
507
519
|
import {
|
|
508
520
|
applyTheme, // 应用主题(自动持久化到 localStorage)
|
|
521
|
+
createThemeBootstrapScript, // 生成可注入 app.html 的首屏主题恢复脚本
|
|
509
522
|
getCurrentTheme, // 获取当前主题
|
|
510
523
|
getThemeInitScript, // 返回可注入到 HTML shell 的防闪烁脚本文本
|
|
511
524
|
isDarkTheme, // 检查是否暗色主题
|
|
525
|
+
normalizeHexColor, // 归一化 #RGB / #RRGGBB 主题色
|
|
526
|
+
normalizeThemeId, // 校验主题 ID 并自动回退
|
|
512
527
|
THEME_GROUPS, // 按亮色/暗色分组
|
|
513
528
|
THEMES, // ThemeInfo[] — 15 个精选主题元数据
|
|
514
529
|
} from '@h-ai/ui'
|
|
515
530
|
```
|
|
516
531
|
|
|
517
|
-
|
|
532
|
+
简单场景可以直接使用 `getThemeInitScript()`;如果应用有自定义存储 key、主题色 CSS 变量或语言偏好,推荐在服务端通过 `createThemeBootstrapScript()` 生成脚本后再注入 `app.html`。
|
|
533
|
+
|
|
534
|
+
在 SvelteKit 的 `app.html` 中,可以先放一个占位符:
|
|
518
535
|
|
|
519
536
|
```html
|
|
520
537
|
<head>
|
|
521
|
-
<script>
|
|
522
|
-
(function(){var t='light';try{var s=localStorage.getItem('theme');if(s)t=s}catch{}document.documentElement.setAttribute('data-theme',t)})()
|
|
523
|
-
</script>
|
|
538
|
+
<script>%theme_bootstrap%</script>
|
|
524
539
|
</head>
|
|
525
540
|
```
|
|
526
541
|
|
|
542
|
+
然后在 `hooks.server.ts` 里注入:
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
import { createThemeBootstrapScript, DEFAULT_THEME_COLOR_CSS_VAR } from '@h-ai/ui'
|
|
546
|
+
|
|
547
|
+
const themeBootstrapScript = createThemeBootstrapScript({
|
|
548
|
+
storageKey: 'hai-demo-preferences',
|
|
549
|
+
legacyThemeStorageKey: 'hai-demo-theme',
|
|
550
|
+
defaultThemeColor: '#5765f0',
|
|
551
|
+
colorCssVar: DEFAULT_THEME_COLOR_CSS_VAR,
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
transformPageChunk: ({ html }) => html.replace('%theme_bootstrap%', themeBootstrapScript)
|
|
555
|
+
```
|
|
556
|
+
|
|
527
557
|
## 国际化 (i18n)
|
|
528
558
|
|
|
529
559
|
@h-ai/ui 采用**组件内置翻译**模式:
|
package/auto-import.d.ts
CHANGED
|
@@ -76,12 +76,14 @@ declare global {
|
|
|
76
76
|
// scenes - app
|
|
77
77
|
const FeedbackModal: typeof import('@h-ai/ui')['FeedbackModal']
|
|
78
78
|
const LanguageSwitch: typeof import('@h-ai/ui')['LanguageSwitch']
|
|
79
|
+
const SettingsLayout: typeof import('@h-ai/ui')['SettingsLayout']
|
|
79
80
|
const SettingsModal: typeof import('@h-ai/ui')['SettingsModal']
|
|
80
81
|
const ThemeColorPicker: typeof import('@h-ai/ui')['ThemeColorPicker']
|
|
81
82
|
const ThemeSelector: typeof import('@h-ai/ui')['ThemeSelector']
|
|
82
83
|
const ThemeToggle: typeof import('@h-ai/ui')['ThemeToggle']
|
|
83
84
|
|
|
84
85
|
// scenes - iam
|
|
86
|
+
const AuthShell: typeof import('@h-ai/ui')['AuthShell']
|
|
85
87
|
const ChangePasswordForm: typeof import('@h-ai/ui')['ChangePasswordForm']
|
|
86
88
|
const ForgotPasswordForm: typeof import('@h-ai/ui')['ForgotPasswordForm']
|
|
87
89
|
const LoginForm: typeof import('@h-ai/ui')['LoginForm']
|
|
@@ -114,6 +116,9 @@ declare global {
|
|
|
114
116
|
const CrudEditPanel: typeof import('@h-ai/ui')['CrudEditPanel']
|
|
115
117
|
const CrudFilterBar: typeof import('@h-ai/ui')['CrudFilterBar']
|
|
116
118
|
const CrudPage: typeof import('@h-ai/ui')['CrudPage']
|
|
119
|
+
|
|
120
|
+
// scenes - error
|
|
121
|
+
const ErrorPage: typeof import('@h-ai/ui')['ErrorPage']
|
|
117
122
|
}
|
|
118
123
|
|
|
119
124
|
export {}
|
package/auto-import.js
CHANGED
|
@@ -66,11 +66,13 @@ const UI_COMPONENTS = new Set([
|
|
|
66
66
|
// scenes - app
|
|
67
67
|
'FeedbackModal',
|
|
68
68
|
'LanguageSwitch',
|
|
69
|
+
'SettingsLayout',
|
|
69
70
|
'SettingsModal',
|
|
70
71
|
'ThemeColorPicker',
|
|
71
72
|
'ThemeSelector',
|
|
72
73
|
'ThemeToggle',
|
|
73
74
|
// scenes - iam
|
|
75
|
+
'AuthShell',
|
|
74
76
|
'ChangePasswordForm',
|
|
75
77
|
'ForgotPasswordForm',
|
|
76
78
|
'LoginForm',
|
|
@@ -99,6 +101,8 @@ const UI_COMPONENTS = new Set([
|
|
|
99
101
|
'CrudEditPanel',
|
|
100
102
|
'CrudFilterBar',
|
|
101
103
|
'CrudPage',
|
|
104
|
+
// scenes - error
|
|
105
|
+
'ErrorPage',
|
|
102
106
|
])
|
|
103
107
|
|
|
104
108
|
const TAG_REGEX = /<([A-Z][A-Za-z0-9]*)\b/g
|
|
@@ -41,10 +41,13 @@
|
|
|
41
41
|
striped = true,
|
|
42
42
|
hoverable = true,
|
|
43
43
|
density = 'normal',
|
|
44
|
+
sortKey,
|
|
45
|
+
sortDir,
|
|
46
|
+
onsort,
|
|
44
47
|
class: className = '',
|
|
45
48
|
}: {
|
|
46
49
|
data: T[]
|
|
47
|
-
columns: { key: keyof T | string, label: string, width?: string, align?: 'left' | 'center' | 'right', render?: (item: T) => string }[]
|
|
50
|
+
columns: { key: keyof T | string, label: string, width?: string, align?: 'left' | 'center' | 'right', sortable?: boolean, render?: (item: T) => string }[]
|
|
48
51
|
keyField: keyof T
|
|
49
52
|
actions?: Snippet<[T]>
|
|
50
53
|
empty?: Snippet
|
|
@@ -52,10 +55,66 @@
|
|
|
52
55
|
striped?: boolean
|
|
53
56
|
hoverable?: boolean
|
|
54
57
|
density?: 'normal' | 'compact'
|
|
58
|
+
/** 受控排序字段(提供 onsort 时由外部驱动,通常用于服务端排序) */
|
|
59
|
+
sortKey?: string
|
|
60
|
+
/** 受控排序方向 */
|
|
61
|
+
sortDir?: 'asc' | 'desc' | null
|
|
62
|
+
/** 排序回调;提供时为受控模式(不再对 data 做本地排序),否则组件内部对当前数据做客户端排序 */
|
|
63
|
+
onsort?: (key: string, dir: 'asc' | 'desc') => void
|
|
55
64
|
class?: string
|
|
56
65
|
} = $props()
|
|
57
66
|
|
|
58
|
-
type Column = { key: keyof T | string, label: string, width?: string, align?: 'left' | 'center' | 'right', render?: (item: T) => string }
|
|
67
|
+
type Column = { key: keyof T | string, label: string, width?: string, align?: 'left' | 'center' | 'right', sortable?: boolean, render?: (item: T) => string }
|
|
68
|
+
|
|
69
|
+
// ─── 排序状态 ───
|
|
70
|
+
const controlled = $derived(typeof onsort === 'function')
|
|
71
|
+
// 非受控模式下的内部排序状态
|
|
72
|
+
let localSortKey = $state<string | null>(null)
|
|
73
|
+
let localSortDir = $state<'asc' | 'desc'>('asc')
|
|
74
|
+
|
|
75
|
+
const activeSortKey = $derived(controlled ? (sortKey ?? null) : localSortKey)
|
|
76
|
+
const activeSortDir = $derived<'asc' | 'desc' | null>(controlled ? (sortDir ?? null) : (localSortKey ? localSortDir : null))
|
|
77
|
+
|
|
78
|
+
function toggleSort(col: Column) {
|
|
79
|
+
if (!col.sortable)
|
|
80
|
+
return
|
|
81
|
+
const key = String(col.key)
|
|
82
|
+
const nextDir: 'asc' | 'desc' = activeSortKey === key && activeSortDir === 'asc' ? 'desc' : 'asc'
|
|
83
|
+
if (controlled) {
|
|
84
|
+
onsort?.(key, nextDir)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
localSortKey = key
|
|
88
|
+
localSortDir = nextDir
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 非受控模式:对当前数据做客户端排序(受控模式直接使用外部已排序数据)
|
|
92
|
+
const sortedData = $derived.by(() => {
|
|
93
|
+
if (controlled || !localSortKey)
|
|
94
|
+
return data
|
|
95
|
+
const key = localSortKey
|
|
96
|
+
const dir = localSortDir
|
|
97
|
+
const copy = [...data]
|
|
98
|
+
copy.sort((a, b) => {
|
|
99
|
+
const av = (a as Record<string, unknown>)[key]
|
|
100
|
+
const bv = (b as Record<string, unknown>)[key]
|
|
101
|
+
if (av === bv)
|
|
102
|
+
return 0
|
|
103
|
+
if (av === null || av === undefined)
|
|
104
|
+
return 1
|
|
105
|
+
if (bv === null || bv === undefined)
|
|
106
|
+
return -1
|
|
107
|
+
let cmp: number
|
|
108
|
+
if (typeof av === 'number' && typeof bv === 'number') {
|
|
109
|
+
cmp = av - bv
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
cmp = String(av).localeCompare(String(bv), undefined, { numeric: true })
|
|
113
|
+
}
|
|
114
|
+
return dir === 'asc' ? cmp : -cmp
|
|
115
|
+
})
|
|
116
|
+
return copy
|
|
117
|
+
})
|
|
59
118
|
|
|
60
119
|
const tableClass = $derived(
|
|
61
120
|
density === 'compact'
|
|
@@ -108,11 +167,29 @@
|
|
|
108
167
|
<thead>
|
|
109
168
|
<tr class={headerRowClass}>
|
|
110
169
|
{#each columns as col (String(col.key))}
|
|
170
|
+
{@const isSorted = activeSortKey === String(col.key)}
|
|
111
171
|
<th
|
|
112
172
|
style={col.width ? `width: ${col.width}` : ''}
|
|
113
173
|
class='{getAlignClass(col.align)} font-medium'
|
|
114
174
|
>
|
|
115
|
-
{col.
|
|
175
|
+
{#if col.sortable}
|
|
176
|
+
<button
|
|
177
|
+
type='button'
|
|
178
|
+
class='group inline-flex select-none items-center gap-1 rounded-md px-1.5 py-0.5 -mx-1.5 font-medium transition-colors hover:bg-base-content/5 {isSorted ? 'text-base-content' : ''}'
|
|
179
|
+
onclick={() => toggleSort(col)}
|
|
180
|
+
>
|
|
181
|
+
<span>{col.label}</span>
|
|
182
|
+
{#if isSorted && activeSortDir === 'asc'}
|
|
183
|
+
<span class='icon-[tabler--arrow-narrow-up] size-3.5 text-base-content/70'></span>
|
|
184
|
+
{:else if isSorted && activeSortDir === 'desc'}
|
|
185
|
+
<span class='icon-[tabler--arrow-narrow-down] size-3.5 text-base-content/70'></span>
|
|
186
|
+
{:else}
|
|
187
|
+
<span class='icon-[tabler--arrows-sort] size-3.5 text-base-content/30 group-hover:text-base-content/50'></span>
|
|
188
|
+
{/if}
|
|
189
|
+
</button>
|
|
190
|
+
{:else}
|
|
191
|
+
{col.label}
|
|
192
|
+
{/if}
|
|
116
193
|
</th>
|
|
117
194
|
{/each}
|
|
118
195
|
{#if actions}
|
|
@@ -138,7 +215,7 @@
|
|
|
138
215
|
</td>
|
|
139
216
|
</tr>
|
|
140
217
|
{:else}
|
|
141
|
-
{#each
|
|
218
|
+
{#each sortedData as item (item[keyField])}
|
|
142
219
|
<tr class={rowClass}>
|
|
143
220
|
{#each columns as col (String(col.key))}
|
|
144
221
|
<td class={getAlignClass(col.align)}>{getValue(item, col)}</td>
|
|
@@ -7,6 +7,7 @@ declare function $$render<T>(): {
|
|
|
7
7
|
label: string;
|
|
8
8
|
width?: string;
|
|
9
9
|
align?: "left" | "center" | "right";
|
|
10
|
+
sortable?: boolean;
|
|
10
11
|
render?: (item: T) => string;
|
|
11
12
|
}[];
|
|
12
13
|
keyField: keyof T;
|
|
@@ -16,6 +17,12 @@ declare function $$render<T>(): {
|
|
|
16
17
|
striped?: boolean;
|
|
17
18
|
hoverable?: boolean;
|
|
18
19
|
density?: "normal" | "compact";
|
|
20
|
+
/** 受控排序字段(提供 onsort 时由外部驱动,通常用于服务端排序) */
|
|
21
|
+
sortKey?: string;
|
|
22
|
+
/** 受控排序方向 */
|
|
23
|
+
sortDir?: "asc" | "desc" | null;
|
|
24
|
+
/** 排序回调;提供时为受控模式(不再对 data 做本地排序),否则组件内部对当前数据做客户端排序 */
|
|
25
|
+
onsort?: (key: string, dir: "asc" | "desc") => void;
|
|
19
26
|
class?: string;
|
|
20
27
|
};
|
|
21
28
|
exports: {};
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
open = $bindable(false),
|
|
12
12
|
title = '',
|
|
13
13
|
size = 'md',
|
|
14
|
+
radius = '1rem',
|
|
14
15
|
closeOnBackdrop = true,
|
|
15
16
|
closeOnEscape = true,
|
|
16
17
|
showClose = true,
|
|
@@ -29,8 +30,10 @@
|
|
|
29
30
|
// 只有调用 `showModal()` 进入 top-layer 后,弹框才不会被父级布局和 overflow 裁剪。
|
|
30
31
|
let modalElement: HTMLDialogElement | undefined = $state()
|
|
31
32
|
|
|
32
|
-
//
|
|
33
|
-
|
|
33
|
+
// 标题区仅在显式提供标题或自定义 header 时出现;
|
|
34
|
+
// 仅需关闭按钮的场景改为右上角悬浮按钮,避免业务侧额外占一整行头部。
|
|
35
|
+
const hasHeader = $derived(Boolean(header || title))
|
|
36
|
+
const hasFloatingClose = $derived(showClose && !hasHeader)
|
|
34
37
|
const sizeMap: Record<string, string> = {
|
|
35
38
|
'xs': 'hai-modal__panel--xs',
|
|
36
39
|
'sm': 'hai-modal__panel--sm',
|
|
@@ -55,12 +58,21 @@
|
|
|
55
58
|
styles.push(`--hai-modal-height: ${height.trim()}`)
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
if (typeof radius === 'string' && radius.trim().length > 0) {
|
|
62
|
+
styles.push(`--hai-modal-radius: ${radius.trim()}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (bodyOverflow === 'visible') {
|
|
66
|
+
styles.push('--hai-modal-panel-overflow: visible')
|
|
67
|
+
}
|
|
68
|
+
|
|
58
69
|
return styles.length > 0 ? styles.join('; ') : undefined
|
|
59
70
|
})
|
|
60
71
|
|
|
61
72
|
const modalBoxClass = $derived(
|
|
62
73
|
cn(
|
|
63
74
|
'hai-modal__panel flex min-h-0 flex-col',
|
|
75
|
+
'relative',
|
|
64
76
|
'bg-base-100 p-0',
|
|
65
77
|
sizeMap[size],
|
|
66
78
|
className,
|
|
@@ -86,6 +98,17 @@
|
|
|
86
98
|
}
|
|
87
99
|
}
|
|
88
100
|
|
|
101
|
+
function handleViewportKeydown(event: KeyboardEvent) {
|
|
102
|
+
if (
|
|
103
|
+
event.target === event.currentTarget
|
|
104
|
+
&& closeOnBackdrop
|
|
105
|
+
&& (event.key === 'Enter' || event.key === ' ')
|
|
106
|
+
) {
|
|
107
|
+
event.preventDefault()
|
|
108
|
+
handleClose()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
89
112
|
function handleDialogCancel(event: Event) {
|
|
90
113
|
// 原生 dialog 在按下 Escape 时会先触发 cancel;
|
|
91
114
|
// 这里统一接管关闭逻辑,避免浏览器直接 close 后跳过组件自己的 onclose 回调。
|
|
@@ -117,74 +140,95 @@
|
|
|
117
140
|
bind:this={modalElement}
|
|
118
141
|
class='hai-modal'
|
|
119
142
|
oncancel={handleDialogCancel}
|
|
120
|
-
onclick={handleDialogClick}
|
|
121
143
|
>
|
|
122
|
-
<div
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
144
|
+
<div
|
|
145
|
+
class='hai-modal__viewport'
|
|
146
|
+
role='button'
|
|
147
|
+
tabindex='-1'
|
|
148
|
+
aria-label={uiM('common_close')}
|
|
149
|
+
onclick={handleDialogClick}
|
|
150
|
+
onkeydown={handleViewportKeydown}
|
|
151
|
+
>
|
|
152
|
+
<div class={modalBoxClass} style={panelStyle}>
|
|
153
|
+
{#if hasFloatingClose}
|
|
154
|
+
<button
|
|
155
|
+
type='button'
|
|
156
|
+
class='hai-modal__close hai-modal__close--floating'
|
|
157
|
+
aria-label={uiM('common_close')}
|
|
158
|
+
onclick={handleClose}
|
|
159
|
+
>
|
|
160
|
+
<svg viewBox='0 0 24 24' class='hai-modal__close-icon' aria-hidden='true'>
|
|
161
|
+
<path
|
|
162
|
+
d='M6 6l12 12M18 6L6 18'
|
|
163
|
+
fill='none'
|
|
164
|
+
stroke='currentColor'
|
|
165
|
+
stroke-width='1.9'
|
|
166
|
+
stroke-linecap='round'
|
|
167
|
+
></path>
|
|
168
|
+
</svg>
|
|
169
|
+
</button>
|
|
170
|
+
{/if}
|
|
137
171
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
172
|
+
{#if hasHeader}
|
|
173
|
+
<!-- 顶部栏单独固定,保证滚动时标题和关闭操作始终可见。 -->
|
|
174
|
+
<div class='hai-modal__header flex flex-none items-start justify-between gap-4 px-6 py-5 sm:px-7'>
|
|
175
|
+
{#if header}
|
|
176
|
+
<div class='flex-1 font-semibold text-[1.05rem] text-base-content/92'>
|
|
177
|
+
{@render header()}
|
|
178
|
+
</div>
|
|
179
|
+
{:else if title}
|
|
180
|
+
<h3 class='flex-1 font-semibold text-[1.05rem] tracking-tight text-base-content/92'>
|
|
181
|
+
{title}
|
|
182
|
+
</h3>
|
|
183
|
+
{:else}
|
|
184
|
+
<div class='flex-1'></div>
|
|
185
|
+
{/if}
|
|
186
|
+
|
|
187
|
+
{#if showClose}
|
|
188
|
+
<button
|
|
189
|
+
type='button'
|
|
190
|
+
class='hai-modal__close'
|
|
191
|
+
aria-label={uiM('common_close')}
|
|
192
|
+
onclick={handleClose}
|
|
193
|
+
>
|
|
194
|
+
<svg viewBox='0 0 24 24' class='hai-modal__close-icon' aria-hidden='true'>
|
|
195
|
+
<path
|
|
196
|
+
d='M6 6l12 12M18 6L6 18'
|
|
197
|
+
fill='none'
|
|
198
|
+
stroke='currentColor'
|
|
199
|
+
stroke-width='1.9'
|
|
200
|
+
stroke-linecap='round'
|
|
201
|
+
></path>
|
|
202
|
+
</svg>
|
|
203
|
+
</button>
|
|
204
|
+
{/if}
|
|
205
|
+
</div>
|
|
206
|
+
{/if}
|
|
207
|
+
|
|
208
|
+
<!-- 主体区域独立滚动,避免长内容把头部和底部一起顶走。 -->
|
|
209
|
+
<div class={cn('min-h-0 flex-1 px-6 py-5 sm:px-7', bodyOverflowClass, bodyClass)}>
|
|
210
|
+
{#if children}
|
|
211
|
+
{@render children()}
|
|
155
212
|
{/if}
|
|
156
213
|
</div>
|
|
157
|
-
{/if}
|
|
158
214
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
215
|
+
{#if footer}
|
|
216
|
+
<!-- 底栏固定在面板底部,用于承接确认/取消或状态摘要。 -->
|
|
217
|
+
<div class='hai-modal__footer flex flex-none items-center px-6 py-4 sm:px-7'>
|
|
218
|
+
<div class='flex w-full items-center justify-end gap-3'>
|
|
219
|
+
{@render footer()}
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
163
222
|
{/if}
|
|
164
223
|
</div>
|
|
165
|
-
|
|
166
|
-
{#if footer}
|
|
167
|
-
<!-- 底栏固定在面板底部,用于承接确认/取消或状态摘要。 -->
|
|
168
|
-
<div class='hai-modal__footer flex flex-none items-center bg-base-100 px-6 py-4 sm:px-7'>
|
|
169
|
-
<div class='flex w-full items-center justify-end gap-3'>
|
|
170
|
-
{@render footer()}
|
|
171
|
-
</div>
|
|
172
|
-
</div>
|
|
173
|
-
{/if}
|
|
174
224
|
</div>
|
|
175
225
|
</dialog>
|
|
176
226
|
|
|
177
227
|
<style>
|
|
178
|
-
dialog.hai-modal
|
|
179
|
-
|
|
180
|
-
width: 100vw;
|
|
181
|
-
min-width: 0;
|
|
182
|
-
max-width: none;
|
|
183
|
-
height: 100vh;
|
|
184
|
-
height: 100dvh;
|
|
185
|
-
max-height: none;
|
|
228
|
+
dialog.hai-modal,
|
|
229
|
+
dialog.hai-modal:modal {
|
|
186
230
|
margin: 0;
|
|
187
|
-
padding:
|
|
231
|
+
padding: 0;
|
|
188
232
|
border: 0;
|
|
189
233
|
background: transparent;
|
|
190
234
|
outline: none;
|
|
@@ -192,9 +236,12 @@
|
|
|
192
236
|
box-sizing: border-box;
|
|
193
237
|
}
|
|
194
238
|
|
|
195
|
-
|
|
239
|
+
.hai-modal__viewport {
|
|
240
|
+
position: fixed;
|
|
241
|
+
inset: 0;
|
|
196
242
|
display: grid;
|
|
197
243
|
place-items: center;
|
|
244
|
+
padding: clamp(1rem, 2.6vw, 2rem);
|
|
198
245
|
}
|
|
199
246
|
|
|
200
247
|
dialog.hai-modal::backdrop {
|
|
@@ -209,8 +256,8 @@
|
|
|
209
256
|
height: var(--hai-modal-height, auto);
|
|
210
257
|
max-height: calc(100vh - clamp(2rem, 5.2vw, 4rem));
|
|
211
258
|
max-height: calc(100dvh - clamp(2rem, 5.2vw, 4rem));
|
|
212
|
-
border-radius:
|
|
213
|
-
overflow: hidden;
|
|
259
|
+
border-radius: var(--hai-modal-radius, 1rem);
|
|
260
|
+
overflow: var(--hai-modal-panel-overflow, hidden);
|
|
214
261
|
color: var(--color-base-content);
|
|
215
262
|
outline: none;
|
|
216
263
|
box-shadow:
|
|
@@ -220,12 +267,18 @@
|
|
|
220
267
|
}
|
|
221
268
|
|
|
222
269
|
.hai-modal__header {
|
|
270
|
+
/* 头部单独承接面板圆角,避免滚动主体或主题背景把顶部边缘切成直角。 */
|
|
271
|
+
border-top-left-radius: inherit;
|
|
272
|
+
border-top-right-radius: inherit;
|
|
223
273
|
box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--color-base-content) 6%, transparent);
|
|
224
274
|
}
|
|
225
275
|
|
|
226
276
|
.hai-modal__footer {
|
|
277
|
+
/* 底栏去掉独立背景后,继续继承圆角,保证底部阴影和面板外轮廓保持一致。 */
|
|
278
|
+
border-bottom-right-radius: inherit;
|
|
279
|
+
border-bottom-left-radius: inherit;
|
|
227
280
|
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--color-base-content) 6%, transparent);
|
|
228
|
-
border-radius: 0 0
|
|
281
|
+
border-radius: 0 0 var(--hai-modal-radius, 1rem) var(--hai-modal-radius, 1rem);
|
|
229
282
|
}
|
|
230
283
|
|
|
231
284
|
.hai-modal__close {
|
|
@@ -262,6 +315,13 @@
|
|
|
262
315
|
height: 1rem;
|
|
263
316
|
}
|
|
264
317
|
|
|
318
|
+
.hai-modal__close--floating {
|
|
319
|
+
position: absolute;
|
|
320
|
+
top: 1rem;
|
|
321
|
+
right: 1rem;
|
|
322
|
+
z-index: 1;
|
|
323
|
+
}
|
|
324
|
+
|
|
265
325
|
.hai-modal__panel--xs {
|
|
266
326
|
--hai-modal-width: min(100%, 24rem);
|
|
267
327
|
}
|
|
@@ -320,7 +380,7 @@
|
|
|
320
380
|
max-width: 100%;
|
|
321
381
|
max-height: calc(100vh - 1.7rem);
|
|
322
382
|
max-height: calc(100dvh - 1.7rem);
|
|
323
|
-
border-radius: 1rem;
|
|
383
|
+
border-radius: min(var(--hai-modal-radius, 1rem), 1rem);
|
|
324
384
|
}
|
|
325
385
|
}
|
|
326
386
|
</style>
|