Files
UI/packages/skin/ui/src/jsonViewer.ts
T
2026-10-26 22:50:05 +08:00

55 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { h } from '@ui-arch/frame';
import type { JsonViewerProps } from './types.js';
// 值类型归类:数组 / 对象算分支,其余算叶子(null 单独一类,避免被 typeof 报成 object)
type JsonKind = 'array' | 'object' | 'string' | 'number' | 'boolean' | 'null';
const kindOf = (v: unknown): JsonKind => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v === 'object' ? 'object' : (typeof v as JsonKind));
/*** 叶子值文案:字符串补引号,其余按字面;null 用灰字斜体在样式层区分 ***/
function leafText(v: unknown): string {
return typeof v === 'string' ? `"${v}"` : String(v);
}
/*** 分支摘要:收起时也要看得出里面有多少内容,否则无法判断值不值得展开 ***/
function summaryOf(v: unknown): string {
if (Array.isArray(v)) return `${v.length} 项`;
const n = Object.keys(v as Record<string, unknown>).length; // 对象分支:按自有键计数
return `${n} 个键`;
}
/*** JSON 树查看:按层折叠展示任意值,数组 / 对象可展开收起,叶子按类型着色;只读、不解析编辑 ***/
export function jsonViewer(props: JsonViewerProps): HTMLElement {
const rootLabel = props.rootLabel ?? 'root'; // 根标签:贴顶那一行的键名
const openDepth = props.depth ?? 2; // 默认展开层数:2 层能看出一级结构又不至于刷屏
const root = h('div', { class: 'ua-json', role: 'tree', 'aria-label': props.ariaLabel ?? 'JSON 数据' }) as HTMLDivElement;
if (props.maxHeight !== undefined) root.style.maxHeight = `${props.maxHeight}px`; // 只在给了上限时才限高,缺省随内容
/*** 单行渲染:分支行带开关与计数摘要,叶子行只带类型类名 ***/
const row = (key: string, value: unknown, level: number, path: string): HTMLElement => {
const kind = kindOf(value);
const branch = kind === 'array' || kind === 'object';
const label = h('span', { class: 'ua-json-key' }, key); // 键名:数组下标也用 key 位展示
if (!branch) {
return h('div', { class: 'ua-json-row is-leaf', role: 'treeitem' }, label, h('span', { class: 'ua-json-colon' }, ':'), h('span', { class: `ua-json-val ua-json-${kind}` }, leafText(value)));
}
const box = h('div', { class: 'ua-json-children', role: 'group' }); // 子节点容器:收起时整块隐藏,DOM 保留折叠态
const caret = h('span', { class: 'ua-json-caret', 'aria-hidden': 'true' }, '▸'); // 开关图标:方向由 aria-expanded 决定样式
const toggle = h('button', {
type: 'button',
class: 'ua-json-toggle',
'aria-expanded': () => String(!box.hidden),
'aria-label': () => `${box.hidden ? '展开' : '收起'} ${path}`,
onClick: () => { box.hidden = !box.hidden; }, // 只翻 hidden:样式按 :has 或 aria-expanded 跟随
}, caret, label, h('span', { class: 'ua-json-colon' }, ':'), h('span', { class: 'ua-json-sum' }, `${kind === 'array' ? '[' : '{'}${summaryOf(value)}${kind === 'array' ? ']' : '}'}`));
const entries: [string, unknown][] = kind === 'array'
? (value as unknown[]).map((v, i) => [String(i), v]) // 数组下标转字符串,走同一套行渲染
: Object.entries(value as Record<string, unknown>);
for (const [k, v] of entries) box.append(row(k, v, level + 1, `${path}.${k}`)); // 递归:路径用点号拼接,报给读屏时能定位
box.hidden = level >= openDepth; // 超出默认层数的一律先收起,用户按需展开
return h('div', { class: 'ua-json-branch', role: 'treeitem', 'aria-level': String(level + 1) }, toggle, box);
};
root.append(row(rootLabel, props.value, 0, rootLabel));
return root;
}