weifuwu 0.64.1 → 0.65.0

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/dist/index.js CHANGED
@@ -4345,6 +4345,65 @@ function createSsrContext(serverCtx, dataStore) {
4345
4345
  },
4346
4346
  usePopupPosition: () => ({ top: 0, left: 0, refresh: () => {
4347
4347
  } }),
4348
+ // 新原语族:SSR 确定性 no-op(不启动监听/会话/取数;组件挂载不崩即契约)
4349
+ useHoverCapable: () => false,
4350
+ useVisualViewport: () => ({ height: 0, offsetTop: 0, keyboardOpen: false }),
4351
+ useLongPress: () => ({}),
4352
+ useInView: () => ({ isIn: false, ready: false, observe: () => {
4353
+ }, refresh: () => {
4354
+ }, disconnect: () => {
4355
+ } }),
4356
+ useScrollPosition: () => ({ y: 0, refresh: () => {
4357
+ } }),
4358
+ useStableRef: (init) => (el) => {
4359
+ if (el) init?.(el);
4360
+ },
4361
+ useGlobalKey: () => () => {
4362
+ },
4363
+ useReducedMotion: () => false,
4364
+ useAnimationEnd: () => () => {
4365
+ },
4366
+ useTween: (target) => {
4367
+ const handle = { value: target, reset: (to) => {
4368
+ handle.value = to;
4369
+ } };
4370
+ return handle;
4371
+ },
4372
+ useDrag: () => ({ onPointerDown: () => {
4373
+ } }),
4374
+ useDragDrop: () => ({ dropProps: {} }),
4375
+ useControlled: (options) => ({
4376
+ value: options.value,
4377
+ setValue: (v) => {
4378
+ options.onChange?.(v);
4379
+ },
4380
+ controlled: options.value !== void 0
4381
+ }),
4382
+ useAsync: () => ({ data: void 0, loading: true, error: void 0, reload: () => {
4383
+ } }),
4384
+ usePopup: () => ({
4385
+ open: false,
4386
+ setOpen: () => {
4387
+ },
4388
+ wrapProps: {},
4389
+ portal: () => null,
4390
+ refresh: () => {
4391
+ }
4392
+ }),
4393
+ usePresence: () => ({
4394
+ phase: "closed",
4395
+ ref: () => {
4396
+ },
4397
+ sync: (open2) => open2 ? "open" : "closed"
4398
+ }),
4399
+ useDialog: () => ({
4400
+ phase: "closed",
4401
+ rootRef: () => {
4402
+ },
4403
+ panelRef: () => {
4404
+ },
4405
+ sync: (open2) => open2 ? "open" : "closed"
4406
+ }),
4348
4407
  // SSR 确定性空态:会话不启动(无事件/无网络),仅保证挂载不崩
4349
4408
  useChat: () => ({
4350
4409
  messages: [],
@@ -5,6 +5,7 @@
5
5
  92 个 HTML 原语组件。每个是 `(_init, ctx) => (props) => VNode`(两阶段组件,与前端框架同一模型),引用 `--wf-*` CSS 变量做主题。另含 `confirm()` / `toast()` 命令式中间件。
6
6
 
7
7
  > **组件速查(weifuwu 组件 ↔ antd / Element Plus / shadcn-ui 对应 + 迁移示例)**:见 [`docs/components-map.md`](components-map.md)——从其他组件库迁来的开发者按功能直接找对应组件。
8
+ > **自定义组件开发**:见 [docs/custom-components.md](custom-components.md)——usePopup/useControlled/对话框/AI 组件/类型纪律逐步指南。
8
9
 
9
10
  ```ts
10
11
  import { Button, Input, Table, Modal, Toast } from 'weifuwu/components'
@@ -0,0 +1,237 @@
1
+ # 自定义组件开发指南
2
+
3
+ > 用 weifuwu/client 写自己的组件——与内置组件同权:同渲染引擎、同弹层原语、同类型安全。
4
+ > 前置:[前端概念](frontend.md)(两阶段模型/ctx.ui)+ [组件列表](components.md)。
5
+
6
+ ---
7
+
8
+ ## 0. 最小骨架
9
+
10
+ ```tsx
11
+ import { h, type Component } from 'weifuwu/client'
12
+
13
+ // Component<P, C>:P = props(JSX 自动推断),C = ctx 注入依赖(默认 {})
14
+ const Badge: Component<{ text: string; color?: string }> = () =>
15
+ (props) => h('span', { class: 'my-badge', style: { color: props.color } }, props.text)
16
+ ```
17
+
18
+ - 两阶段:外层 `(initProps, ctx) => …` 只执行一次(mount),内层 `(props) => VNode` 每次渲染执行
19
+ - 不需要状态就不调 `ctx.ui.$()`;需要交互才用
20
+
21
+ ## 1. 有状态组件
22
+
23
+ ```tsx
24
+ const Toggle: Component = (_init, ctx) => {
25
+ const $ = ctx.ui.$() // 深度 Proxy:赋值自动触发渲染(微任务批量)
26
+ $.on = false
27
+
28
+ return (props) => h('button', {
29
+ class: 'my-toggle',
30
+ onClick: () => $.on = !$.on,
31
+ }, $.on ? '开' : '关')
32
+ }
33
+ ```
34
+
35
+ | 状态类型 | 存放位置 | 触发渲染 |
36
+ |---------|---------|---------|
37
+ | 自动 UI 状态 | `$.xxx` | 赋值自动(微任务) |
38
+ | 手动 UI 状态 | 闭包 `let` | 需 `ctx.ui.render()` |
39
+ | 内部缓存 | 闭包 `let` | 不触发 |
40
+
41
+ ## 2. 带弹层的组件(最高频的自定义场景)
42
+
43
+ 用 `ctx.ui.usePopup`——一个组合器收敛 open 状态 + 触发(hover→tap 降级/longpress)+ Escape + 外部点击 + 定位/视口 clamp + portal:
44
+
45
+ ```tsx
46
+ const MyPopover: Component<{ content: string }> = (_init, ctx) => {
47
+ const $ = ctx.ui.$()
48
+ $.open = false
49
+ let wrapEl: HTMLElement | null = null
50
+ const wrapRef = (el: HTMLElement | null) => { wrapEl = el }
51
+
52
+ const popup = ctx.ui.usePopup({
53
+ trigger: 'hover', // 触屏自动降级 tap(useHoverCapable 内部判定)
54
+ el: () => wrapEl, // 锚点
55
+ isOpen: () => $.open,
56
+ setOpen: (v) => { $.open = v }, // $ 赋值自动渲染
57
+ width: 320, // 自动 clamp 视口
58
+ closeOnOutside: true, // 外部点击关闭(默认)
59
+ closeOnEscape: true, // Escape 关闭(默认,document 级——portal 焦点也生效)
60
+ })
61
+
62
+ return (props) =>
63
+ h('span', { class: 'anchor', ref: wrapRef, ...popup.wrapProps },
64
+ props.children,
65
+ popup.portal(h('div', { class: 'wf-panel' }, props.content)),
66
+ )
67
+ }
68
+ ```
69
+
70
+ > **受控弹层**:传 `open` getter + `onOpenChange` 即受控(父组件独占开关);缺 onOpenChange 时 usePopup 内部 warn 提示。
71
+
72
+ ## 3. 对话框类组件(Modal 系)
73
+
74
+ 全屏对话框(焦点 trap + 滚动锁 + 退场动画)不在 usePopup 范围——用 **`ctx.ui.useDialog`** 组合器(Modal/Drawer 同款:退场状态机 + 滚动锁 + 焦点 trap + animationend 卸载):
75
+
76
+ ```tsx
77
+ import { createPortal } from 'weifuwu/client'
78
+
79
+ const MyDialog: Component<{ open: boolean; onClose: () => void }> = (_init, ctx) => {
80
+ const dialog = ctx.ui.useDialog({ name: 'MyDialog' }) // mount 创建
81
+
82
+ return (props) => {
83
+ const phase = dialog.sync(!!props.open) // render 同步 open
84
+ if (phase === 'closed') return null
85
+
86
+ return createPortal(h('div', {
87
+ class: 'wf-overlay',
88
+ onClick: (e: any) => { if (e.target === e.currentTarget) props.onClose() },
89
+ }, h('div', {
90
+ class: `wf-modal ${phase === 'exit' ? 'wf-modal--exit' : 'wf-modal--enter'}`,
91
+ ref: dialog.panelRef, // 焦点 trap 目标
92
+ onKeyDown: (e: any) => { if (e.key === 'Escape') props.onClose() }, // Escape 语义组件层
93
+ }, props.children)), document.body)
94
+ }
95
+ }
96
+ ```
97
+
98
+ > `dialog.rootRef` 挂到 portal 根(lockScroll + animationend 退场监听);`panelRef` 挂到面板(trapFocus)。
99
+ > 低层原语 `trapFocus`/`lockScroll`/`animateOut` 仍从 `weifuwu/client` 导出(特殊场景组装用)。
100
+
101
+ ## 4. AI 组件
102
+
103
+ 会话语义由 `ctx.ui.useChat` 提供(消息/流式/工具/审批/stop/retry 全封装),返回的 handle 与 `$` 同一容器:
104
+
105
+ ```tsx
106
+ const ChatPanel: Component = (_init, ctx) => {
107
+ const $ = ctx.ui.useChat({
108
+ url: '/api/chat',
109
+ approveUrl: '/api/approve', // HITL 审批上行(缺省 approve() 只清卡片)
110
+ body: (messages) => ({ messages, mode: 'agent' }),
111
+ })
112
+
113
+ return () => h('div', { class: 'chat' },
114
+ $.messages.map((m) => h('div', { class: `msg-${m.role}` }, m.content)),
115
+ h('input', { value: $.input, onInput: (e: any) => $.input = e.target.value }),
116
+ h('button', { onClick: () => $.send() }, $.streaming ? '…' : '发送'),
117
+ )
118
+ }
119
+ ```
120
+
121
+ **共享 `$` 给子组件**(如 `<AiChat chat={$} />`):父组件 dirty 不驱动子组件(三态 skip),子组件 mount 期 `initProps.chat.__watch?.(() => ctx.ui.dirty())` 自订阅。
122
+
123
+ ## 5. 异步组件(数据声明在工厂层)
124
+
125
+ ```tsx
126
+ import { asyncComponent } from 'weifuwu/client'
127
+
128
+ const UserCard = asyncComponent(async (ctx) => {
129
+ const user = await ctx.data.get(`/api/user/${ctx.route.params.id}`) // 工厂层取数(SSR 序列化进 HTML)
130
+ return (_init, ctx) => {
131
+ const $ = ctx.ui.$()
132
+ $.liked = false
133
+ return (props) => h('div', {}, user.name, h('button', { onClick: () => $.liked = !$.liked }))
134
+ }
135
+ })
136
+ ```
137
+
138
+ - 工厂只执行一次(WeakMap 缓存);客户端首次渲染占位 → resolve 后整树补全;服务端直接 await(无占位)
139
+ - **个性化数据不进 ctx.data**(SSR 会序列化给所有客户端)——留在客户端 `$` + fetch
140
+
141
+ ## 6. 类型纪律(编译期防线)
142
+
143
+ ```tsx
144
+ const Badge: Component<{ variant: 'primary' | 'muted' }> = () =>
145
+ (props) => h('span', { class: `badge-${props.variant}` }, props.children)
146
+
147
+ // 负例:variant 传错 → tsc 报错(@ts-expect-error 是类型流测试的写法)
148
+ // @ts-expect-error variant 不允许 'bogus'
149
+ const bad: { variant: 'primary' | 'muted' } = { variant: 'bogus' }
150
+
151
+ // ctx 注入声明(C 泛型):声明了才能用,未声明编译期报错
152
+ const Page: Component<{}, { api: ApiInjected['api'] }> = (_init, ctx) => {
153
+ ctx.api.get('/x')
154
+ return () => null
155
+ }
156
+ ```
157
+
158
+ | 规则 | 违反后果 |
159
+ |------|---------|
160
+ | `Component<P, C>` 类型化(禁 `_init: any`) | 编译期不可查 |
161
+ | 受控 props 必须配回调 | 交互静默失效(组件 console.warn) |
162
+ | ref 用 `ctx.ui.useStableRef`(禁内联 ref) | 清理逻辑每次渲染误触 |
163
+ | 初始状态确定性(禁 `window.innerWidth` 直接初始化) | SSR/hydration mismatch |
164
+ | 小尺寸按钮固定 min/max-height | 被全局 36px 撑成竖条 |
165
+
166
+ ## 7. 测试写法
167
+
168
+ ```tsx
169
+ function renderVNode(Comp: any, props: any, ctx: any) {
170
+ const result = Comp(props, ctx)
171
+ return typeof result === 'function' ? result(props) : result
172
+ }
173
+
174
+ const ctx = { ui: { $: () => ({}), render: () => {}, dirty: () => {}, useControlled: (o: any) => ({ value: o.value, setValue: o.onChange ?? (() => {}), controlled: o.value !== undefined }) } }
175
+ const vnode = renderVNode(Toggle, {}, ctx)
176
+ // 断言 vnode 结构(子组件 VNode.type 是组件函数,不是标签名)
177
+ ```
178
+
179
+ - 类型流测试:`@ts-expect-error` 负例(见 [type-flow.test.ts](../src/components/type-flow.test.ts))
180
+ - 组件测试跑在 node --test;DOM 事件级测试需 `document.body.appendChild(container)`
181
+
182
+ ## 8. 受控组件标准
183
+
184
+ 新受控组件**必须**用 `ctx.ui.useControlled`(受控判定 + 缺回调 warn 一次 + 非受控内部状态跨渲染保持):
185
+
186
+ ```tsx
187
+ const CollapseItem: Component<{ active?: boolean; onChange?: (v: boolean) => void }> = (_init, ctx) => {
188
+ return (props) => {
189
+ const ctrl = ctx.ui.useControlled<boolean>({ value: props.active, onChange: props.onChange, name: 'CollapseItem' })
190
+ return h('button', {
191
+ onClick: () => ctrl.setValue(!(ctrl.value ?? false)), // 受控走 onChange;非受控内部状态
192
+ }, (ctrl.value ?? false) ? '开' : '关')
193
+ }
194
+ }
195
+ ```
196
+
197
+ > 多受控维度组件(Tree 的 expandedKeys+checkedKeys)不适用单值 useControlled——保留手工受控判定(参考 Tree.ts),warn 文案与 useControlled 保持一致。
198
+
199
+ ## 8.5 动画(4 层能力)
200
+
201
+ | 层 | 原语 | 用法 |
202
+ |----|------|------|
203
+ | CSS 语言 | Token(`--wf-dur-*`/`--wf-ease-*`/`--wf-motion-*`)+ `--enter`/`--exit` 成对 | 组件动画统一引用 Token,禁硬编码 |
204
+ | 生命周期 | `useAnimationEnd(cb, { once })`(完成回调)/ `usePresence({ name })`(显隐状态机)/ `animateOut(el, done)`(命令式退场) | 入场 settle / 退场延迟卸载 / 命令式播动画 |
205
+ | 数值驱动 | `useTween(target, { duration, ease })`(补间)/ `useInView` / `useScrollPosition` | count-up / 进入视口播 / 滚动联动 |
206
+ | 偏好感知 | `useReducedMotion()` | JS 动画(rAF/tween)侧跳过;CSS 动画 `_base.css` 已全局降级 |
207
+
208
+ **纪律**:组件内动画事件监听**唯一入口是 `useAnimationEnd`**——禁直接 `addEventListener('animationend')`(DatePicker 已收敛);退场优先 `usePresence`(声明式状态机)或 `animateOut`(命令式)。
209
+
210
+ ## 9. 样式纪律(style-audit 强制)
211
+
212
+ - 动效用 Token:`--wf-dur-*` / `--wf-ease-*` / `--wf-motion-*`(禁硬编码)
213
+ - 语义色用 `-text` 变体(700 级);实心填充文字用 `--wf-color-on-brand`;遮罩 `--wf-overlay`
214
+ - 图标用 `Icon` 组件(禁裸文本字形 ✕✓⚠▲)
215
+ - 触屏(coarse pointer)自动 44px 命中区
216
+
217
+ ---
218
+
219
+ ## 内置组件 = 最佳实践范本
220
+
221
+ | 想要的能力 | 参考源码 |
222
+ |-----------|---------|
223
+ | 弹层组合(hover/click/longpress) | [Tooltip.ts](../src/components/Tooltip/Tooltip.ts) / [ContextMenu.ts](../src/components/ContextMenu/ContextMenu.ts) |
224
+ | 对话框状态机 | [Modal.ts](../src/components/Modal/Modal.ts) / [Drawer.ts](../src/components/Drawer/Drawer.ts) |
225
+ | AI 会话 | [AiChat.ts](../src/components/AiChat/AiChat.ts) |
226
+ | 受控 + 键盘导航 | [Collapse.ts](../src/components/Collapse/Collapse.ts) / [Tabs.ts](../src/components/Tabs/Tabs.ts) |
227
+ | 异步数据 | [UserProfile](../src/components/Img/Img.ts) 的 factory 模式 |
228
+ | 命令式 API(toast/confirm) | [Toast.ts](../src/components/Toast/Toast.ts) |
229
+
230
+ ## 已知边界(诚实裁剪)
231
+
232
+ - `usePopup` 覆盖浮层(Tooltip/Popover/Dropdown/Mentions/Cascader/ContextMenu);**全屏对话框**(Modal/Drawer)用 useDialog(Escape 语义留组件层);Command/Img preview 保持独立实现
233
+ - **事件监听纪律**:组件库内部浏览器事件监听**统一走 `ctx.ui.useXXX`**——滚动/观察/弹层/对话框/快捷键/拖拽/DnD 全覆盖:
234
+ `useInView`(InfiniteScroll)、`useScrollPosition`(AiChat/Affix/BackTop/VirtualList)、`usePopupPosition`(Affix 阈值重算)、`usePopup`(ContextMenu 自由定位)、`useDialog`(Modal/Drawer)、`useGlobalKey`(Command 快捷键/Img preview Escape)、`useDrag`(Resizable)、`useDragDrop`(FileUpload)、`useControlled`/`useStableRef`(状态/ref)
235
+ - 唯一保留:**DatePicker 元素级 `animationend`**(入场动画完成后坐标 settle——与 usePopup panelRef/motion.animateOut 同款框架基础设施,非浏览器全局事件)
236
+ - **Select/DatePicker** 是 inline/absolute 菜单(自适宽),不迁移 usePopup——菜单直接挂在锚点下
237
+ - `createReactiveState` 已导出:组件外建全局 store(`createReactiveState(() => {})` + `$.__watch(cb)` 订阅)
package/docs/frontend.md CHANGED
@@ -155,6 +155,16 @@ ctx.ui.render(['name'])
155
155
  | `selfId()` | `selfId(name: string)` | 注册组件自定义 ID,配合 `render(['id'])` 跨组件精准刷新 |
156
156
  | `useChat()` | `useChat({ url, approveUrl?, body? })` | AI 对话会话:消息/流式/工具/审批,与 `$` 同容器(AiChat 配套) |
157
157
  | `useAsync()` | `useAsync(fetcher)` | 异步取数:`data/loading/error` 响应式 + `reload()` |
158
+ | `useControlled()` | `useControlled({ value, onChange, name })` | 受控/非受控统一:受控判定 + 缺回调 warn + 内部状态跨渲染保持 |
159
+ | `useStableRef()` | `useStableRef(init, cleanup?)` | 稳定 ref 引用(根治内联 ref 陷阱) |
160
+ | `useDialog()` | `useDialog({ name })` | 全屏对话框:退场状态机 + 滚动锁 + 焦点 trap(Modal/Drawer 同款) |
161
+ | `useGlobalKey()` | `useGlobalKey(handler)` | 全局键盘监听(window keydown:mount 注册 + 卸载清理) |
162
+ | `useDrag()` | `useDrag({ onMove, onStart?, onEnd? })` | 指针拖拽(pointerdown 捕获 → window move delta / up 释放) |
163
+ | `useDragDrop()` | `useDragDrop({ onDrop, onDragOver?, onDragLeave? })` | 原生 DnD(drop/dragover/dragleave + preventDefault,dropProps spread) |
164
+ | `useReducedMotion()` | `useReducedMotion()` | 响应式系统偏好(JS 动画侧跳过;CSS 动画已有全局降级) |
165
+ | `useAnimationEnd()` | `useAnimationEnd(cb, { once? })` | 元素动画完成回调(stableRef:挂载绑定/卸载清理/引用恒定) |
166
+ | `useTween()` | `useTween(target, { duration?, ease? })` | 数值补间(rAF + easeOutCubic + reduced-motion 直落;幂等 reset) |
167
+ | `usePresence()` | `usePresence({ name? })` | 通用显隐状态机(open→exit→closed,animationend 延迟卸载;useDialog 是其特例) |
158
168
  | `useMedia()` | `useMedia(query, cb)` | 响应式媒体查询,断点变化时自动回调 |
159
169
  | `useBreakpoint()` | `useBreakpoint(cb \| bps, cb?)` | 命名断点 mobile/tablet/desktop |
160
170
  | `usePopupPosition()` | `usePopupPosition(opts)` | 弹层坐标跟随:scroll/resize 时自动重算 fixed 坐标 |
@@ -462,6 +472,39 @@ return () => list.loading ? h(Loading) : list.data?.map(u => h('div', {}, u.name
462
472
  - `list.data` / `list.loading` / `list.error` 赋值自动 dirty 当前组件
463
473
  - `list.reload()` 重跑;组件卸载后旧 Promise resolve 不再触发渲染(idRegistry 查无此组件,安全忽略)
464
474
 
475
+ #### 动画原语(4 层能力)
476
+
477
+ 动画能力按层组织(CSS 语言已有:`--wf-dur-*`/`--wf-ease-*`/`--wf-motion-*` Token + `--enter`/`--exit` 成对纪律):
478
+
479
+ | 原语 | 层 | 说明 |
480
+ |------|----|------|
481
+ | `useAnimationEnd(cb, { once? })` | 生命周期 | 元素动画完成回调(stableRef:挂载绑定/卸载清理/引用恒定)——**组件内动画事件唯一入口** |
482
+ | `usePresence({ name? })` | 生命周期 | 显隐状态机:open → exit → closed(animationend 延迟卸载);`useDialog` 是其对话框特例 |
483
+ | `useTween(target, { duration?, ease? })` | 数值驱动 | 数值补间(rAF + easeOutCubic + reduced-motion 直落;幂等 reset + 每帧自动渲染) |
484
+ | `useReducedMotion()` | 偏好感知 | 响应式系统偏好——**JS 动画**(rAF/tween)侧跳过(CSS 动画已有全局降级) |
485
+ | `useInView` / `useScrollPosition` | 数值驱动 | 进入视口播 / 滚动位置联动(已有) |
486
+
487
+ ```tsx
488
+ // 入场 settle(面板坐标夹紧:动画期间矩形非稳态,结束后按稳态几何计算)
489
+ const settleRef = ctx.ui.useAnimationEnd(() => pos.refresh(), { once: true })
490
+ return () => h('div', { class: 'wf-panel', ref: settleRef }, ...)
491
+
492
+ // 退场(显隐状态机:open=false 播退场动画,animationend 后才真正卸载)
493
+ const { phase, ref, sync } = ctx.ui.usePresence()
494
+ const p = sync(props.open)
495
+ if (p === 'closed') return null
496
+ return h('div', { class: `wf-panel ${p === 'exit' ? '--exit' : '--enter'}`, ref }, ...)
497
+
498
+ // 数值动画(count-up:0 → 42,每帧自动渲染)
499
+ const n = ctx.ui.useTween(42, { duration: 400 })
500
+ return () => h('span', { class: 'wf-nums' }, String(n.value))
501
+
502
+ // 偏好感知(JS 动画侧跳过;CSS 动画 _base.css 已全局降级)
503
+ if (!ctx.ui.useReducedMotion()) { /* 启动 rAF/动画 */ }
504
+ ```
505
+
506
+ > 完整动画纪律见 [custom-components.md](custom-components.md) 的「8.5 动画」章节。
507
+
465
508
  #### CSS 层响应式(不碰 JS)
466
509
 
467
510
  配合 `weifuwu/layout` 的断点变体,纯 CSS 实现布局方向切换:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "weifuwu",
3
3
  "type": "module",
4
- "version": "0.64.1",
4
+ "version": "0.65.0",
5
5
  "description": "AI SaaS framework — (req, ctx) => Response",
6
6
  "exports": {
7
7
  ".": {