weifuwu 0.55.2 → 0.56.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.
@@ -7,7 +7,7 @@
7
7
  * --jsxImportSource=weifuwu/client
8
8
  */
9
9
  import type { WfuiContext } from './types.ts';
10
- export type VNodeType = string | Component | typeof Fragment | typeof Portal;
10
+ export type VNodeType = string | Component | AsyncComponent | typeof Fragment | typeof Portal;
11
11
  export interface VNode {
12
12
  type: VNodeType;
13
13
  props: Record<string, any>;
@@ -23,6 +23,8 @@ export interface VNode {
23
23
  _render?: (props: any) => VNode | null;
24
24
  /** 组件实例 ID(如 '_wf_0') */
25
25
  _id?: string;
26
+ /** 自定义组件 ID(ctx.ui.selfId() 注册,跨组件精准刷新) */
27
+ _customId?: string;
26
28
  /** 组件输出的 DOM 父节点 */
27
29
  _parentNode?: Node;
28
30
  /** 组件输出的第一个 DOM 节点 */
@@ -35,6 +37,35 @@ export interface VNode {
35
37
  * P = props 类型(JSX 自动推断),C = 组件依赖的 ctx 注入(如 ApiInjected & RouteInjected)
36
38
  */
37
39
  export type Component<P = {}, C extends object = {}> = (initProps: P, ctx: WfuiContext & C) => ((props: P) => VNode | null) | null;
40
+ /**
41
+ * 异步组件工厂(形态 C):async (ctx) => (initProps, ctx) => (props) => VNode
42
+ *
43
+ * 工厂层(async,只执行一次并缓存):
44
+ * - 数据声明:const data = await ctx.data.get(key, fetcher)
45
+ * - 代码分割:const { default: def } = await import('./view.tsx')
46
+ * - 异步初始化:模块加载、共享资源
47
+ *
48
+ * 返回标准的 Component——mount/render 保持同步,异步只发生在工厂边界。
49
+ * 服务端遍历器与客户端渲染器都 await 工厂;更新路径(_render 缓存)不碰工厂。
50
+ *
51
+ * 必须用 asyncComponent() 包装以标记(渲染器据此区分调用约定):
52
+ * Component → Comp(initProps, ctx)
53
+ * AsyncComponent → await Comp(ctx) → Component
54
+ */
55
+ export type AsyncComponent<C extends object = {}, P = {}> = (ctx: WfuiContext & C) => Promise<Component<P, C>>;
56
+ /**
57
+ * 包装异步组件工厂。
58
+ *
59
+ * ```tsx
60
+ * const UserProfile = asyncComponent(async (ctx) => {
61
+ * const user = await ctx.data.get(`/api/user/${ctx.params.id}`)
62
+ * return (initProps, ctx) => (props) => h('div', {}, user.name)
63
+ * })
64
+ * ```
65
+ */
66
+ export declare function asyncComponent<C extends object = {}, P = {}>(factory: AsyncComponent<C, P>): AsyncComponent<C, P>;
67
+ /** 判定一个组件类型是否为 async 工厂(asyncComponent 包装过) */
68
+ export declare function isAsyncComponent(type: any): type is AsyncComponent;
38
69
  export declare const Fragment: unique symbol;
39
70
  /** Portal — 将子 VNode 渲染到 document.body 下的独立容器 */
40
71
  export declare const Portal: unique symbol;
@@ -22,5 +22,7 @@ export interface TableProps {
22
22
  onSort?: (key: string, order: 'asc' | 'desc') => void;
23
23
  /** 数据为空时显示的文本 */
24
24
  emptyText?: string;
25
+ /** 表格最小宽度(窄屏横向滚动,如 '720px') */
26
+ minWidth?: string;
25
27
  }
26
28
  export declare const Table: Component<TableProps>;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * weifuwu/components — ThemeSwitch 主题切换器
3
+ *
4
+ * 三段式切换:auto(跟随系统偏好)/ light(强制亮色)/ dark(强制暗色)。
5
+ * 对应 layout 的暗色双段激活机制:
6
+ * - auto → 移除 data-theme,由 @media (prefers-color-scheme) 决定
7
+ * - light → <html data-theme="light">(系统暗色也强制亮色)
8
+ * - dark → <html data-theme="dark">
9
+ *
10
+ * mount 时读取 localStorage 并立即应用(避免闪白);
11
+ * 用户选择后持久化。
12
+ */
13
+ import type { Component } from '../../client/vnode.ts';
14
+ export type ThemeMode = 'auto' | 'light' | 'dark';
15
+ export interface ThemeSwitchProps {
16
+ /** 初始模式(默认从 localStorage 读取,无记录时为 auto) */
17
+ mode?: ThemeMode;
18
+ /** 切换回调 */
19
+ onChange?: (mode: ThemeMode) => void;
20
+ /** localStorage 存储 key */
21
+ storageKey?: string;
22
+ }
23
+ /** 应用主题:auto 移除属性,light/dark 显式设置 */
24
+ export declare function applyTheme(mode: ThemeMode): void;
25
+ /** 读取当前生效主题(localStorage 优先,其次系统偏好) */
26
+ export declare function getTheme(): ThemeMode;
27
+ export declare const ThemeSwitch: Component<ThemeSwitchProps>;
@@ -89,3 +89,6 @@ export { Chart } from './Chart/Chart.ts';
89
89
  export type { ChartProps, ChartType, DataPoint, ChartOptions } from './Chart/Chart.ts';
90
90
  export { Editor } from './Editor/Editor.ts';
91
91
  export type { EditorProps, ToolbarItem } from './Editor/Editor.ts';
92
+ export { ThemeSwitch } from './ThemeSwitch/ThemeSwitch.ts';
93
+ export type { ThemeSwitchProps, ThemeMode } from './ThemeSwitch/ThemeSwitch.ts';
94
+ export { applyTheme, getTheme } from './ThemeSwitch/ThemeSwitch.ts';
@@ -1,4 +1,8 @@
1
1
  // src/client/vnode.ts
2
+ var ASYNC_MARK = "__wfAsyncComponent";
3
+ function isAsyncComponent(type) {
4
+ return typeof type === "function" && type?.[ASYNC_MARK] === true;
5
+ }
2
6
  var Fragment = /* @__PURE__ */ Symbol("Fragment");
3
7
  var Portal = /* @__PURE__ */ Symbol("Portal");
4
8
  function h(type, props, ...children) {
@@ -296,6 +300,7 @@ var Table = (_init, _ctx) => (props) => {
296
300
  return h("th", {
297
301
  class: `wf-table-th${col.sortable ? " wf-table-th--sortable" : ""}${isSorted ? " wf-table-th--sorted" : ""}`,
298
302
  scope: "col",
303
+ tabindex: col.sortable ? 0 : void 0,
299
304
  style: col.width ? { width: col.width } : void 0,
300
305
  onClick: col.sortable && onSort ? () => onSort(col.key, isSorted && isAsc ? "desc" : "asc") : void 0
301
306
  }, label);
@@ -327,7 +332,11 @@ var Table = (_init, _ctx) => (props) => {
327
332
  });
328
333
  }
329
334
  const tbody = h("tbody", { class: "wf-table-tbody" }, bodyRows);
330
- return h("table", { class: "wf-table" }, [thead, tbody]);
335
+ const table = h("table", {
336
+ class: "wf-table",
337
+ style: props.minWidth ? { minWidth: props.minWidth } : void 0
338
+ }, [thead, tbody]);
339
+ return h("div", { class: "wf-table-wrap" }, table);
331
340
  };
332
341
 
333
342
  // src/client/scroll-lock.ts
@@ -462,10 +471,11 @@ function renderValue(v, ctx) {
462
471
  }
463
472
  if (vnode.type === Fragment) {
464
473
  const frag = document.createDocumentFragment();
465
- forEach(vnode.props?.children, (child) => {
474
+ const children = vnode.props?.children == null ? [] : Array.isArray(vnode.props.children) ? vnode.props.children : [vnode.props.children];
475
+ for (const child of children) {
466
476
  const node = renderValue(child, ctx);
467
477
  if (node != null) frag.appendChild(node);
468
- });
478
+ }
469
479
  return frag;
470
480
  }
471
481
  if (typeof vnode.type === "function") {
@@ -508,6 +518,54 @@ function renderValue(v, ctx) {
508
518
  if (typeof vnode.props?.ref === "function") vnode.props.ref(el);
509
519
  return el;
510
520
  }
521
+ var asyncFactoryCache = /* @__PURE__ */ new WeakMap();
522
+ function startAsyncFactory(Comp, ctx) {
523
+ const existing = asyncFactoryCache.get(Comp);
524
+ if (existing) return existing;
525
+ const entry = { promise: null };
526
+ entry.promise = Promise.resolve().then(() => Comp(ctx)).then((def) => {
527
+ if (typeof def !== "function") {
528
+ throw new Error(
529
+ `asyncComponent factory <${Comp.name || "anonymous"}> must return a Component (initProps, ctx) => (props) => VNode.`
530
+ );
531
+ }
532
+ entry.resolved = def;
533
+ return def;
534
+ });
535
+ asyncFactoryCache.set(Comp, entry);
536
+ return entry;
537
+ }
538
+ function resolveAsyncFactorySync(Comp) {
539
+ return asyncFactoryCache.get(Comp)?.resolved;
540
+ }
541
+ function scheduleFullReRender(ctx) {
542
+ const ui = ctx.ui;
543
+ if (ui && typeof ui.render === "function") ui.render(["_wf_root"]);
544
+ }
545
+ function mountComponent(Comp, props, vnode, ctx) {
546
+ let def;
547
+ if (isAsyncComponent(Comp)) {
548
+ def = resolveAsyncFactorySync(Comp);
549
+ if (!def) {
550
+ void startAsyncFactory(Comp, ctx).promise.then(
551
+ () => scheduleFullReRender(ctx),
552
+ () => {
553
+ }
554
+ );
555
+ return null;
556
+ }
557
+ } else {
558
+ def = Comp;
559
+ }
560
+ let childVNode = def(props, ctx);
561
+ if (typeof childVNode !== "function") {
562
+ throw new Error(
563
+ `Component ${Comp.name || "anonymous"} must return a render function. Use (init_props, ctx) => (props) => VNode pattern.`
564
+ );
565
+ }
566
+ vnode._render = childVNode;
567
+ return childVNode(props);
568
+ }
511
569
  function renderComponent(Comp, props, vnode, ctx) {
512
570
  ;
513
571
  ctx.ui = ctx.ui ?? {};
@@ -522,14 +580,7 @@ function renderComponent(Comp, props, vnode, ctx) {
522
580
  vnode._ctxVersion = childCtx.ui._ctxVersion ?? 0;
523
581
  let childVNode;
524
582
  try {
525
- childVNode = Comp(props, childCtx);
526
- if (typeof childVNode !== "function") {
527
- throw new Error(
528
- `Component ${Comp.name || "anonymous"} must return a render function. Use (init_props, ctx) => (props) => VNode pattern.`
529
- );
530
- }
531
- vnode._render = childVNode;
532
- childVNode = childVNode(props);
583
+ childVNode = mountComponent(Comp, props, vnode, childCtx);
533
584
  } catch (e) {
534
585
  const errHandler = ctx.ui?._errorHandler;
535
586
  if (errHandler) {
@@ -586,14 +637,6 @@ function renderPortal(vnode, ctx) {
586
637
  if (node != null) sub.appendChild(node);
587
638
  }
588
639
  }
589
- function forEach(children, fn) {
590
- if (children == null) return;
591
- if (Array.isArray(children)) {
592
- children.forEach(fn);
593
- return;
594
- }
595
- fn(children);
596
- }
597
640
  function flattenChildren(children) {
598
641
  if (children == null) return [];
599
642
  if (!Array.isArray(children)) return [children];
@@ -655,6 +698,15 @@ function cleanupPortalChildren(vnode) {
655
698
  function callRefCleanup(input) {
656
699
  if (input == null || typeof input !== "object") return;
657
700
  const vnode = input;
701
+ if (vnode._id) {
702
+ if (vnode._customId) idRegistry.delete(vnode._customId);
703
+ idRegistry.delete(vnode._id);
704
+ vnode._id = void 0;
705
+ vnode._customId = void 0;
706
+ vnode._render = void 0;
707
+ vnode._parentNode = void 0;
708
+ vnode._refNode = void 0;
709
+ }
658
710
  if (vnode._child != null) {
659
711
  if (Array.isArray(vnode._child)) {
660
712
  for (const child of vnode._child) {
@@ -2970,6 +3022,68 @@ var Editor = (_props, ctx) => {
2970
3022
  ]);
2971
3023
  };
2972
3024
  };
3025
+
3026
+ // src/components/ThemeSwitch/ThemeSwitch.ts
3027
+ var DEFAULT_KEY = "wf_theme";
3028
+ function readStored(key) {
3029
+ try {
3030
+ const v = localStorage.getItem(key);
3031
+ return v === "light" || v === "dark" || v === "auto" ? v : null;
3032
+ } catch {
3033
+ return null;
3034
+ }
3035
+ }
3036
+ function writeStored(key, mode) {
3037
+ try {
3038
+ localStorage.setItem(key, mode);
3039
+ } catch {
3040
+ }
3041
+ }
3042
+ function applyTheme(mode) {
3043
+ if (typeof document === "undefined") return;
3044
+ if (mode === "auto") document.documentElement.removeAttribute("data-theme");
3045
+ else document.documentElement.setAttribute("data-theme", mode);
3046
+ }
3047
+ function getTheme() {
3048
+ const stored = readStored(DEFAULT_KEY);
3049
+ if (stored) return stored;
3050
+ return "auto";
3051
+ }
3052
+ var ThemeSwitch = (initProps, ctx) => {
3053
+ const storageKey = initProps.storageKey ?? DEFAULT_KEY;
3054
+ let mode = initProps.mode ?? readStored(storageKey) ?? "auto";
3055
+ applyTheme(mode);
3056
+ return (props) => {
3057
+ const SL = ctx?.i18n?.components?.ThemeSwitch ?? {};
3058
+ const modes = [
3059
+ { value: "auto", label: SL.auto ?? "\u81EA\u52A8" },
3060
+ { value: "light", label: SL.light ?? "\u4EAE\u8272" },
3061
+ { value: "dark", label: SL.dark ?? "\u6697\u8272" }
3062
+ ];
3063
+ const segments = modes.map(
3064
+ (m) => h("button", {
3065
+ type: "button",
3066
+ class: `wf-theme-seg${mode === m.value ? " wf-theme-seg--active" : ""}`,
3067
+ role: "radio",
3068
+ "aria-checked": String(mode === m.value),
3069
+ "aria-label": m.label,
3070
+ onClick: () => {
3071
+ if (mode === m.value) return;
3072
+ mode = m.value;
3073
+ applyTheme(mode);
3074
+ writeStored(storageKey, mode);
3075
+ props.onChange?.(mode);
3076
+ ctx.ui.render();
3077
+ }
3078
+ }, m.label)
3079
+ );
3080
+ return h("div", {
3081
+ class: "wf-theme-switch",
3082
+ role: "radiogroup",
3083
+ "aria-label": SL.label ?? "\u4E3B\u9898\u5207\u6362"
3084
+ }, segments);
3085
+ };
3086
+ };
2973
3087
  export {
2974
3088
  Accordion,
2975
3089
  Alert,
@@ -3011,8 +3125,11 @@ export {
3011
3125
  Tabs,
3012
3126
  Tag,
3013
3127
  Textarea,
3128
+ ThemeSwitch,
3014
3129
  Toast,
3015
3130
  Tooltip,
3131
+ applyTheme,
3016
3132
  confirm,
3133
+ getTheme,
3017
3134
  toast
3018
3135
  };