Repository Wiki
Brandon030722/ark-ui-skill

React 组件包

assets/react/ArkUI.jsx 是一个单文件的 React 组件包(仅依赖 React 自身),与配套样式表 assets/react/ark-ui.css 一起,把「视觉家族(theme)× 信息密度(depth)」双维度设计系统封装为 7 个可组合的受控组件和 3 组导出常量。整个换肤与密度切换完全由 CSS 自定义属性 + data-* 属性选择器驱动,JS 层不做任何样式计算。

Purpose and Scope

本页覆盖 React 组件包的完整机制:

  • assets/react/ArkUI.jsx 的模块结构、全部导出(常量与组件)、每个组件的内部实现与可访问性行为;
  • assets/react/ark-ui.css 的设计令牌体系:.arkR-shell 基线变量、[data-ark-theme] 家族变量、[data-ark-depth] 密度变量,以及它们如何级联到子组件规则;
  • 主题切换、深度切换、移动端菜单、Tabs 键盘导航的真实控制流;
  • 组件 props、CSS 变量、失败模式与扩展点。

不属于本页的内容(留给兄弟页面):

  • 5 个视觉家族对应的纯 HTML 静态展示页(assets/showcases/ark.html、endfield.html、exa.html、popucom.html、corporate.html)——它们是同一设计系统的非 React 呈现,本页仅在「家族对照」处引用其命名;
  • 宣传页资产 assets/promo/(promo.html / promo.css / promo.js)与 README 插图 assets/readme/family-depth-map.zh-CN.svg。

Overview

组件包解决的问题是:用一套组件代码,同时表达 5 种截然不同的品牌视觉家族与 4 档信息密度,并且让两者可以正交组合(5 × 4 = 20 种外观,无需写 20 套样式)。

关键概念:

概念载体取值
视觉家族 theme根节点 data-ark-theme 属性 + CSS 家族变量ark / endfield / exa / popucom / corporate
信息密度 depth根节点 data-ark-depth 属性 + CSS 密度变量minimal(L1) / moderate(L2) / complex(L3) / maximal(L4)
主题档案 profileJS 常量 arkThemeProfiles每个家族一份默认 brand / code / status 文案

设计意图:JS 只负责语义结构(class、ARIA、data 属性),CSS 只负责外观(变量 + 规则)。切换主题时 React 仅改写根节点的一个属性字符串,浏览器按属性选择器重新解析自定义属性,整棵子树随之换肤——没有内联样式、没有 CSS-in-JS 运行时、没有 JS 端颜色计算。

Architecture

Loading diagram...

分层说明:

  • 消费方:页面把 ArkShell 作为最外层外壳,其余组件作为 children 组合进 <main>,ArkThemePicker / ArkDepthPicker 通常放在内容区顶部的控制面板里。
  • JS 模块层:ArkUI.jsx 第一行 import './ark-ui.css' 保证样式随组件包一起被引入;useState(菜单 / Tab 状态)、useEffect(导航后自动收起菜单)、useId(Tabs 的 SSR 安全 ID)是仅有的三个 React API。
  • CSS 层:.arkR-shell 上的变量先被基线块赋值,再被 [data-ark-theme](两段:颜色段 + 家族圆角/分隔线段)和 [data-ark-depth](密度段)覆盖;所有组件规则(.arkR-panel、.arkR-button、.arkR-tabs…)只消费 var(--ark-*),因此对家族与密度天然解耦。

模块结构

ArkUI.jsx 共 168 行,导出 3 个常量与 7 个函数组件,全部为命名导出(无默认导出):

导出行号类别职责
arkThemesL4常量家族名数组,驱动 ArkThemePicker 渲染
arkThemeProfilesL5-L11常量每个家族的默认 brand / code / status 档案
arkDepthsL12-L17常量4 档密度的 value / level / label,驱动 ArkDepthPicker
ArkShellL19-L67布局顶栏 + 左侧导航栏 + 主内容区三段式外壳
ArkSectionTitleL69-L77展示章节大标题(kicker + 序号 + 巨型 h2)
ArkPanelL79-L88展示内容卡片,支持 paper / ink 两种色调
ArkButtonL90-L92展示带 signal 色侧标的按钮,透传原生 props
ArkThemePickerL94-L107交互家族切换按钮组(aria-pressed 单选语义)
ArkDepthPickerL109-L122交互密度切换按钮组(aria-pressed 单选语义)
ArkTabsL124-L167交互完整 WAI-ARIA Tabs 模式(roving tabIndex + 方向键)

模块级数据源如下——注意 arkDepths 的 level 字段被 ArkDepthPicker 直接渲染为按钮文案({depth.level} / {depth.label}),value 才是写入 data-ark-depth 的实际键:

jsx
1export const arkThemes = ['ark', 'endfield', 'exa', 'popucom', 'corporate']; 2export const arkThemeProfiles = { 3 ark: { brand: 'TERRA INDEX', code: 'OPERATION / 07', status: 'SHIFT ACTIVE' }, 4 endfield: { brand: 'FIELD RELAY', code: 'LOGISTICS / 04', status: 'ROUTE VERIFIED' }, 5 exa: { brand: 'WIND ATLAS', code: 'JOURNEY / 03', status: 'RECORD ALIGNED' }, 6 popucom: { brand: 'PRISM PLAZA', code: 'PARTY ROOM / 204', status: '2 OF 4 READY' }, 7 corporate: { brand: 'STUDIO INDEX', code: 'PROJECTS / 05', status: 'PORTFOLIO OPEN' }, 8}; 9export const arkDepths = [ 10 { value: 'minimal', level: 1, label: 'Minimal' }, 11 { value: 'moderate', level: 2, label: 'Moderate' }, 12 { value: 'complex', level: 3, label: 'Complex' }, 13 { value: 'maximal', level: 4, label: 'Maximal' }, 14];

Source: assets/react/ArkUI.jsx

设计意图:把「可选值」与「展示文案」收敛为模块级单一事实来源。ArkThemePicker / ArkDepthPicker 遍历这两个常量渲染按钮,因此新增一个家族只需同时改 JS 常量与 CSS 变量块(见「扩展点」),不需要触碰任何组件实现。

Core Flow

主题切换(theme)的端到端路径

Loading diagram...

要点:

  1. ArkThemePicker 是受控组件——自身不持有选中态,只读取 value 渲染 aria-pressed,变更通过 onChange?.(theme) 回调抛给页面(L94-L107)。可选链调用意味着允许无回调的纯展示用法。
  2. 换肤的唯一动作是 ArkShell 根节点上的 data-ark-theme={theme}(L36)。CSS 端用两段属性选择器完成覆盖:颜色段(L13-L16)+ 家族形制段(L17-L21,圆角 --ark-family-radius 与分隔线 --ark-family-rule)。
  3. ArkShell 对未知家族做了降级:arkThemeProfiles[theme] || arkThemeProfiles.endfield(L31),无效值回退到 endfield 的品牌文案,避免顶栏出现 undefined。

密度切换(depth)与移动端菜单流

ArkDepthPicker 与主题选择器同构(L109-L122),区别仅在于渲染 arkDepths 且按钮文案带档位号。CSS 端 [data-ark-depth] 段(L9-L12)一次性定义三个变量:

css
1.arkR-shell[data-ark-depth="minimal"]{--ark-depth-rule:12%;--ark-depth-panel-min:15rem;--ark-depth-shadow:0 0 0 transparent} 2.arkR-shell[data-ark-depth="moderate"]{--ark-depth-rule:18%;--ark-depth-panel-min:18rem;--ark-depth-shadow:2px 2px 0 color-mix(in srgb,var(--ark-ink),transparent 90%)} 3.arkR-shell[data-ark-depth="complex"]{--ark-depth-rule:24%;--ark-depth-panel-min:22rem;--ark-depth-shadow:5px 5px 0 color-mix(in srgb,var(--ark-ink),transparent 86%)} 4.arkR-shell[data-ark-depth="maximal"]{--ark-depth-rule:34%;--ark-depth-panel-min:26rem;--ark-depth-shadow:9px 9px 0 color-mix(in srgb,var(--ark-signal),transparent 42%)}

Source: assets/react/ark-ui.css

三个变量在规则层各司其职(见 L35 的 .arkR-panel):--ark-depth-rule 控制面板边框与背景的混合透明度(密度越高边框越"实")、--ark-depth-panel-min 控制 min-height、--ark-depth-shadow 从 transparent(minimal 完全无投影)逐级放大到 9px 的 signal 色硬阴影(maximal)。此外 L38 还有少量点状覆写:minimal 档把品牌角标 border-width 收窄为 1px、去掉 clip-path;maximal 档给顶栏叠加 32px 重复的竖向栅格纹理。

ArkShell 内部唯一的自持状态是移动端菜单 menuOpen,配合一条副作用实现"导航即收起":

jsx
1export function ArkShell({ 2 brand, code, status, 3 theme = 'endfield', depth = 'complex', 4 nav = [], activeId, onNavigate, children, 5}) { 6 const [menuOpen, setMenuOpen] = useState(false); 7 const profile = arkThemeProfiles[theme] || arkThemeProfiles.endfield; 8 9 useEffect(() => setMenuOpen(false), [activeId]); 10 11 return ( 12 <div className="arkR-shell" data-ark-theme={theme} data-ark-depth={depth}> 13 <header className="arkR-topbar"> 14 <div className="arkR-brand"> 15 <span className="arkR-brandMark" aria-hidden="true" /> 16 <span><strong>{brand || profile.brand}</strong><small>{code || profile.code}</small></span> 17 </div> 18 <span className="arkR-online"><i aria-hidden="true" /> {status || profile.status}</span> 19 <button 20 className="arkR-menu" 21 type="button" 22 aria-expanded={menuOpen} 23 aria-controls="arkR-rail" 24 onClick={() => setMenuOpen((value) => !value)} 25 >Menu</button> 26 </header> 27 <nav className="arkR-rail" id="arkR-rail" data-open={menuOpen} aria-label="Primary"> 28 {nav.map((item, index) => ( 29 <button 30 key={item.id} 31 type="button" 32 className={item.id === activeId ? 'is-active' : undefined} 33 aria-current={item.id === activeId ? 'page' : undefined} 34 onClick={() => onNavigate?.(item.id)} 35 > 36 <span>{String(index + 1).padStart(2, '0')}</span>{item.label} 37 </button> 38 ))} 39 </nav> 40 <main className="arkR-main">{children}</main> 41 </div> 42 ); 43}

Source: assets/react/ArkUI.jsx

逐点解读:

  • 双维度属性写在一个根节点上(L36):data-ark-theme 与 data-ark-depth 同节点共存,CSS 端两条属性选择器串行覆盖同一批基础变量,天然正交。
  • 属性优先(props > profile):brand || profile.brand 的三重回退链是"显式 props → 家族档案 → (被 || 保证的)endfield 默认",页面可不传任何文案字段直接拿到该家族的默认顶栏。
  • 可访问性闭环:菜单按钮 aria-expanded + aria-controls="arkR-rail" 与 <nav id="arkR-rail" data-open> 一一对应;导航按钮用 aria-current="page" 标记当前项。注意 aria-controls 直接写死了 id arkR-rail——因为整个 ArkShell 页面只应出现一次;若同页渲染两个 ArkShell 会产生 id 冲突(见「失败模式」)。
  • useEffect(() => setMenuOpen(false), [activeId]):依赖数组只含 activeId,即"选中项一变就强制收起菜单"。这是刻意的收敛——移动端点击导航项后侧栏必须自动关闭,否则遮挡内容;同时该 effect 在首挂载时也会执行一次(无害,本来就是 false)。

ArkTabs:完整 ARIA Tabs 模式

ArkTabs 是包内最复杂的组件,实现了 WAI-ARIA Tabs 的 roving tabindex 键盘模式:

jsx
1export function ArkTabs({ items = [], label = 'Details' }) { 2 const baseId = useId(); 3 const [selected, setSelected] = useState(items[0]?.id); 4 5 function onKeyDown(event, index) { 6 if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; 7 event.preventDefault(); 8 let next = index; 9 if (event.key === 'ArrowLeft') next = (index - 1 + items.length) % items.length; 10 if (event.key === 'ArrowRight') next = (index + 1) % items.length; 11 if (event.key === 'Home') next = 0; 12 if (event.key === 'End') next = items.length - 1; 13 setSelected(items[next].id); 14 document.getElementById(`${baseId}-tab-${items[next].id}`)?.focus(); 15 } 16 17 return ( 18 <div className="arkR-tabs"> 19 <div className="arkR-tabList" role="tablist" aria-label={label}> 20 {items.map((item, index) => ( 21 <button 22 key={item.id} 23 id={`${baseId}-tab-${item.id}`} 24 type="button" 25 role="tab" 26 aria-selected={selected === item.id} 27 aria-controls={`${baseId}-panel-${item.id}`} 28 tabIndex={selected === item.id ? 0 : -1} 29 onClick={() => setSelected(item.id)} 30 onKeyDown={(event) => onKeyDown(event, index)} 31 >{String(index + 1).padStart(2, '0')} / {item.label}</button> 32 ))} 33 </div> 34 {items.map((item) => ( 35 <section 36 key={item.id} 37 id={`${baseId}-panel-${item.id}`} 38 role="tabpanel" 39 aria-labelledby={`${baseId}-tab-${item.id}`} 40 hidden={selected !== item.id} 41 >{item.content}</section> 42 ))} 43 </div> 44 ); 45}

Source: assets/react/ArkUI.jsx

控制流解读:

  • 非受控选中态:selected 由组件内部 useState(items[0]?.id) 初始化,未暴露受控 props——与 ArkThemePicker 的受控设计形成对比:Tabs 的选中被视为纯展示关注点,主题/密度则是页面级状态。
  • 键盘模式:ArrowLeft / ArrowRight 使用 (index ± 1 + len) % len 环形取模,Home / End 跳首尾。切换后手动 focus() 目标 tab——这正是 roving tabindex 的正确实现:tabIndex={selected === item.id ? 0 : -1} 保证 Tab 键只进入当前选中项,方向键在项间"游走"。
  • 面板策略:所有 tabpanel 始终渲染在 DOM 中,仅用 HTML hidden 属性切换显隐。相比条件渲染(卸载/重挂载),保留 DOM 意味着切换 Tab 不丢失面板内部状态与滚动位置,也便于打印/搜索到全部内容。
  • ID 体系:baseId 来自 useId()(React 18+,SSR 安全),再拼 -tab-{id} / -panel-{id} 两套 id,用 aria-controls / aria-labelledby 双向绑定。用 ?. 调 focus(),即使未来出现 id 漂移也只是不聚焦而不抛错。
  • 联动样式:L39 的 .arkR-tabList button[aria-selected="true"]:before 用 signal 色画选中态左竖条——样式选择器直接消费 ARIA 状态,状态与外观天然一致,无需额外 class。

CSS 令牌体系(数据模型)

组件包的"数据模型"就是三层 CSS 自定义属性的级联。基线块(默认 = endfield 家族配色)如下:

css
1.arkR-shell { 2 --ark-ink:#191919; --ark-paper:#f2f2f0; --ark-signal:#fffa00; --ark-state:#00ffa2; 3 ... 4} 5.arkR-shell[data-ark-theme="ark"]{--ark-ink:#080a0b;--ark-paper:#f4f6f6;--ark-signal:#18d1ff;--ark-state:#c8eb21} 6.arkR-shell[data-ark-theme="exa"]{--ark-ink:#080914;--ark-paper:#f3f2ef;--ark-signal:#46f6e6;--ark-state:#925dff;--ark-display:"Noto Serif SC","Source Han Serif SC",serif} 7.arkR-shell[data-ark-theme="popucom"]{--ark-ink:#141414;--ark-paper:#fffdf4;--ark-signal:#ffcc1a;--ark-state:#3994ff} 8.arkR-shell[data-ark-theme="corporate"]{--ark-ink:#050505;--ark-paper:#f3f3f3;--ark-signal:#f3ff00;--ark-state:#fff}

Source: assets/react/ark-ui.css

家族 × 密度对照表

家族(theme)--ark-ink / --ark-paper--ark-signal / --ark-state--ark-family-radius家族特化(L40-L41)
ark#080a0b / #f4f6f6#18d1ff / #c8eb210顶栏硬底色;ink 面板叠加 3rem 网格底纹
endfield(默认)#191919 / #f2f2f0#fffa00 / #00ffa22px仅基线,无覆写
exa#080914 / #f3f2ef#46f6e6 / #925dff2rem衬线显示字体 --ark-display;侧栏暗底、全套圆角化、按钮侧标变圆点
popucom#141414 / #fffdf4#ffcc1a / #3994ff1.35rem—
corporate#050505 / #f3f3f3#f3ff00 / #ffffff0—
密度(depth)--ark-depth-rule--ark-depth-panel-min--ark-depth-shadow附加覆写(L38)
minimal(L1)12%15rem无(transparent)品牌角标 1px 边、无 clip-path;章节 kicker 前缀变窄
moderate(L2)18%18rem2px ink 混合阴影—
complex(L3,默认)24%22rem5px ink 混合阴影—
maximal(L4)34%26rem9px signal 色阴影顶栏 32px 重复竖栅格底纹

设计意图:颜色变量(--ark-* 四色 + --ark-display 字体)与形制变量(--ark-family-radius / --ark-family-rule)刻意分成两个选择器块(L13-L16 与 L17-L21),因为二者语义不同:前者是"品牌色板",后者是"品牌形状语言"。同样,密度变量只描述"边框/尺寸/阴影强度"三件事,任何新规则只要继续消费这批 var(),就自动获得全部 20 种组合的外观,无需为每个家族写专门规则——exa 的全套圆角化(L41)是极少数需要家族特化规则的例外。

Usage Examples

基础用法:最小可运行页面骨架

jsx
1import { ArkShell, ArkSectionTitle, ArkPanel, ArkButton } from './assets/react/ArkUI.jsx'; 2 3function App() { 4 return ( 5 <ArkShell 6 theme="exa" // 值必须来自 arkThemes 7 depth="moderate" // 值必须来自 arkDepths 8 nav={[ 9 { id: 'overview', label: 'Overview' }, 10 { id: 'detail', label: 'Detail' }, 11 ]} 12 activeId="overview" 13 > 14 <ArkSectionTitle index="02" kicker="Field Notes">Wind Atlas</ArkSectionTitle> 15 <ArkPanel code="JOURNEY / 03" title="Route Summary" tone="ink"> 16 <p>面板正文……</p> 17 <ArkButton primary type="submit">Confirm</ArkButton> 18 </ArkPanel> 19 </ArkShell> 20 ); 21}

Source: assets/react/ArkUI.jsx

说明:省略 brand / code / status 时顶栏自动取 arkThemeProfiles.exa 的 WIND ATLAS / JOURNEY / 03 / RECORD ALIGNED;省略 theme / depth 时分别落到默认值 endfield / complex。ArkButton 透传全部原生 button props(...props),因此 type="submit"、disabled、onClick 等均可直接使用。

进阶用法:主题/密度实时切换(受控组合)

jsx
1import { useState } from 'react'; 2import { 3 ArkShell, ArkThemePicker, ArkDepthPicker, ArkTabs, 4 arkThemes, arkThemeProfiles, arkDepths, 5} from './assets/react/ArkUI.jsx'; 6 7function Lab() { 8 const [theme, setTheme] = useState(arkThemes[1]); // 'endfield' 9 const [depth, setDepth] = useState(arkDepths[2].value); // 'complex' 10 11 return ( 12 <ArkShell theme={theme} depth={depth} nav={[]} > 13 <ArkThemePicker value={theme} onChange={setTheme} /> 14 <ArkDepthPicker value={depth} onChange={setDepth} /> 15 <ArkTabs 16 label="Details" 17 items={[ 18 { id: 'spec', label: 'Spec', content: <p>……</p> }, 19 { id: 'usage', label: 'Usage', content: <p>……</p> }, 20 ]} 21 /> 22 </ArkShell> 23 ); 24}

Source: assets/react/ArkUI.jsx

要点:value 初值直接从导出常量派生(arkThemes[1]、arkDepths[2].value),保证选择器按钮组、根节点属性与 CSS 选择器三处的键永远一致;两个 picker 的 onChange 直接用 setTheme / setDepth 函数引用,省去中间包装。

纯 CSS 侧:消费设计令牌的自定义规则

css
1/* 在项目样式表中追加,即可让自定义元素进入家族/密度体系 */ 2.myCallout { 3 border: 1px solid color-mix(in srgb, var(--ark-ink), transparent calc(100% - var(--ark-depth-rule))); 4 background: var(--ark-paper); 5 box-shadow: var(--ark-depth-shadow); 6 border-radius: var(--ark-family-radius, 0); 7}

Sources:

只要该元素位于 .arkR-shell 子树内,.myCallout 便会随 theme/depth 切换同步换装——这正是"规则只消费 var(--ark-*)"分层带来的免费扩展能力。

API Reference

所有组件均为命名导出的函数组件,无默认导出。以下签名均摘自源码。

arkThemes: string[]

家族名数组 ['ark', 'endfield', 'exa', 'popucom', 'corporate'],是 data-ark-theme 合法键的单一事实来源。

arkThemeProfiles: Record<string, {brand, code, status}>

每个家族的默认顶栏文案档案;未传 props 时由 ArkShell 回退使用,未知键回退 endfield。

arkDepths: Array<{value, level, label}>

密度档位数组(value 为 CSS 键、level 为档位号、label 为按钮文案),是 data-ark-depth 合法键的单一事实来源。

ArkShell(props): JSX.Element

Parameters(均来自 L19-L29):

Prop类型必填默认说明
brandstring否家族档案 profile.brand顶栏品牌名,优先级高于档案
codestring否profile.code品牌名下的小字代码
statusstring否profile.status顶栏右侧在线状态文案
themestring否'endfield'写入 data-ark-theme;未知值仅回退文案,不回退属性
depthstring否'complex'写入 data-ark-depth
navArray<{id, label}>否[]左侧导航按钮组
activeIdstring否—当前选中项 id,驱动 aria-current 并触发菜单收起副作用
onNavigate(id) => void否—导航点击回调,可选链调用
childrenReactNode否—渲染进 <main className="arkR-main">

内部状态: menuOpen: boolean(移动端菜单)。

ArkSectionTitle({ index = '01', kicker, children })

章节标题。渲染 kicker / {index} 小字与巨型 h2(字号由 --ark-display + clamp() 控制)。

ArkPanel({ code, title, tone = 'paper', children, action })

内容卡片。

Prop类型默认说明
codestring—左上角等宽小字代码
titlestring—卡片标题(h3)
tone'paper' | 'ink''paper'写入 data-tone;CSS 侧 [data-tone="ink"] 反转为 ink 底白字
childrenReactNode—卡片正文(.arkR-panelBody)
actionReactNode—卡片底部操作区(不包 wrapper,直接插入 article 末尾)

ArkButton({ primary = false, className = '', ...props })

按钮。拼装 class:`arkR-button ${primary ? 'is-primary' : ''} ${className}`.trim()(L91),其余 props 原样透传给 <button>。primary 追加 is-primary(ink 底白字),空白字符串经 .trim() 规避。

ArkThemePicker({ value, onChange }) / ArkDepthPicker({ value, onChange })

受控按钮组:role="group",遍历 arkThemes / arkDepths 渲染按钮,选中项标 aria-pressed={value === 键}(CSS 侧以 [aria-pressed="true"] 画选中态),点击触发 onChange?.(键)。ArkDepthPicker 按钮文案为 {level} / {label}。

ArkTabs({ items = [], label = 'Details' })

Parameters:

  • items(Array<{id, label, content}>,默认 []):标签页集合;content 为 ReactNode。
  • label(string,默认 'Details'):role="tablist" 的 aria-label。

内部状态: selected(初始 items[0]?.id,非受控)。键盘: ArrowLeft / ArrowRight(环形)、Home / End,切换后聚焦目标 tab。

Configuration Options

组件包没有运行时配置文件,全部"配置"通过 props 与 CSS 变量表达:

配置项载体类型默认说明
视觉家族ArkShell prop theme → data-ark-theme'ark'|'endfield'|'exa'|'popucom'|'corporate''endfield'选择 CSS 家族变量块
信息密度ArkShell prop depth → data-ark-depth'minimal'|'moderate'|'complex'|'maximal''complex'选择 CSS 密度变量块
顶栏文案brand / code / statusstring家族档案值显式 props 覆盖档案
卡片色调ArkPanel prop tone'paper'|'ink''paper'卡片反色开关
选中态外观CSS [aria-pressed="true"] 规则—signal 底修改该规则即改 picker 选中样式
焦点环CSS .arkR-shell :focus-visible—2px solid var(--ark-signal)随家族自动换色

Failure Modes, Edge Cases & Concurrency

  • 未知 theme 值:arkThemeProfiles[theme] || arkThemeProfiles.endfield(L31)只回退顶栏文案;根节点仍会写入非法 data-ark-theme,导致 CSS 端既不命中任何家族变量块,也就回落到 .arkR-shell 基线(恰为 endfield 配色)。净效果是外观降级安全,但 theme 应始终取自 arkThemes。
  • 未知 depth 值:无 JS 校验;非法值使 [data-ark-depth] 全不命中,--ark-depth-* 三个变量未定义。.arkR-panel 的 min-height: var(--ark-depth-panel-min) 与 box-shadow: var(--ark-depth-shadow) 将退化为初始值(无最小高、无阴影),边框 color-mix 中未定义变量会使整条声明失效——面板视觉明显塌陷,属静默失败。
  • 多个 ArkShell 同页:aria-controls="arkR-rail" 与 id="arkR-rail" 是硬编码 id(L47、L51),第二个实例会产生重复 id,破坏可访问性关联(对比 ArkTabs 已用 useId())。组件包隐含"单 Shell 页面"约定。
  • ArkTabs 空数组:items = [] 时 useState(items[0]?.id) 得 undefined,items.map 渲染空 tablist 与空面板——安全空态,无异常。
  • ArkTabs 的 items 运行时变更:selected 只在挂载时初始化。若后续 items 换成不含当前 selected 的集合,所有 tab 的 aria-selected 均为 false、所有面板 hidden,页面出现"全隐藏"状态;onKeyDown 中的 items[next] 因 next 来自旧数组边界,理论上可越界(items.length 取的是新数组长度)。调用方应保证 key 稳定或以 key={...} 强制重挂载。
  • 并发/重入:组件包全部状态为组件局部 useState,无共享可变模块状态、无外部 store、无网络/持久化副作用,天然无并发竞态;setMenuOpen((value) => !value) 使用函数式更新,多次快速点击也安全。
  • onNavigate?.() / onChange?.() 可选链:未传回调时静默跳过而非抛错,允许纯静态演示场景复用同一组件。

Performance & Operational Notes

  • 换肤成本模型:theme/depth 切换只改根节点一个属性字符串,React 提交极小;浏览器随后对子树做自定义属性重解析。相比 CSS-in-JS 方案(逐元素写内联样式)或 JS 端主题对象映射,这是接近零 JS 成本的路径。
  • useId 时机:ArkTabs 的 id 在 SSR/Hydration 下稳定,组件可在服务端渲染环境直接使用(组件包无 window/document 顶层访问;document.getElementById 仅出现在键盘事件处理器内)。
  • CSS 打包假设:import './ark-ui.css'(L2)要求构建链支持从 JS 导入 CSS(Vite/Webpack/css-loader 等常见方案均满足);纯 CDN/无构建场景需手动以 <link> 引入样式表。
  • 依赖面:仅 react 一个运行时依赖(useEffect/useId/useState),无第三方库;useId 要求 React 18+。
  • 可维护性观察:样式表中大量单行多规则压缩(如 L35、L39),修改时注意这些行的级联顺序——家族特化规则(L40-L41)位于通用规则之后,靠源码顺序取胜,重排会改变 exa 圆角等特化是否生效。

Extension Points

  • 新增视觉家族(4 步,互不触碰组件实现):
    1. arkThemes 数组追加键名(L4);
    2. arkThemeProfiles 追加该家族的 brand / code / status(L5-L11);
    3. CSS 增加颜色段 .arkR-shell[data-ark-theme="<键>"]{--ark-ink:…;--ark-paper:…;--ark-signal:…;--ark-state:…} 与形制段 --ark-family-radius / --ark-family-rule(参照 L13-L21);
    4. 需要家族特化时再追加 .arkR-shell[data-ark-theme="<键>"] .arkR-… 覆写规则(参照 L40-L41 的 ark 网格底纹、exa 圆角化)。ArkThemePicker 因遍历 arkThemes 自动多出一个按钮。
  • 新增密度档:向 arkDepths 追加 {value, level, label},并在 CSS 增加 [data-ark-depth="<value>"] 变量块(参照 L9-L12);ArkDepthPicker 自动渲染。
  • 自定义组件接入体系:在 .arkR-shell 子树内新增任意元素,规则只消费 var(--ark-*) 令牌即可免费获得 5×4 组合外观(见 Usage Examples 的纯 CSS 示例)。
  • 导航内容扩展:nav 项当前仅使用 id 与 label,且渲染时自动生成 String(index + 1).padStart(2, '0') 序号(L60)——可安全附加图标等额外字段而不影响现有行为。

注:仓库内未发现 ArkUI.jsx 的自动化测试文件或其余直接 import 该模块的源文件(基于本次源码检索范围),故本页无 Tests 小节;上方案例均为按源码 props 契约编写的组合示例。

Sources

(1 files)