跳转到内容

皮肤与样式分层

@xihan-ui/styles纯 CSS 包:不依赖任何 JS 包,也不被任何 JS 包依赖。它可以脱离整个 JS 层单独使用,也可以整包丢掉自己写。

层序

CSS 的级联顺序由 @layer 声明的首次出现顺序决定,与 @import 顺序无关。库把这一句单独放在一个文件里,作为唯一事实源:

css
/* layers.css */
@layer xihan.reset, xihan.tokens, xihan.motion, xihan.components, xihan.overrides;
内容
xihan.reset库自己的基线,只作用于带 data-scope 的节点
xihan.tokens令牌声明(来自 @xihan-ui/tokens/tokens.css
xihan.motion关键帧动画
xihan.components组件皮肤
xihan.overrides留给使用者,永远盖得住上面全部

按组件挑样式时必须先引 layers.css,否则层序不成立,级联顺序就变成了引入顺序。

reset 只碰自己的节点

css
@layer xihan.reset {
  [data-scope],
  [data-scope]::before,
  [data-scope]::after {
    box-sizing: border-box;
  }

  [data-scope]:where(button, input, optgroup, select, textarea) {
    font: inherit;
    letter-spacing: inherit;
  }
}

没有全局 reset,不碰宿主页面的任何元素。与现有页面共存不需要做任何隔离。

皮肤的写法

选择器只认 data-*,不认类名:

css
@layer xihan.components {
  [data-scope='button'][data-part='root'] {
    /* 私有槽位:组件令牌 → 语义令牌两级回退 */
    --xh-_bg: var(--xh-button-bg, var(--xh-bg-subtle));
    --xh-_bg-hover: var(--xh-button-bg-hover, var(--xh-bg-subtle-hover));
    --xh-_fg: var(--xh-button-fg, var(--xh-fg-default));

    block-size: var(--xh-button-h, var(--xh-control-h-md));
    padding-inline: var(--xh-button-px, var(--xh-control-px-md));
    border-radius: var(--xh-button-radius, var(--xh-shape-control));
    background: var(--xh-_bg);
    color: var(--xh-_fg);
  }

  /* 变体只改槽位,不重写整条规则 */
  [data-scope='button'][data-part='root'][data-variant='solid'] {
    --xh-_bg: var(--xh-bg-brand);
    --xh-_fg: var(--xh-fg-on-brand);
  }

  [data-scope='button'][data-part='root'][data-disabled] {
    cursor: not-allowed;
    opacity: 0.5;
  }
}

三层变量各司其职:

形式名字谁写
组件覆盖槽--xh-button-bg使用者:想改这个组件的背景就设它
语义令牌--xh-bg-subtle令牌产物:改它影响全库
皮肤私有槽--xh-_bg皮肤内部:变体只改它,规则本身不重复

带下划线前缀的私有槽不是公开接口,别在外面设它。

覆盖样式的三种粒度

css
/* 1. 改语义令牌:影响所有直接消费它的规则 */
:root {
  --xh-shape-control: 10px;
}

/* 2. 改组件覆盖槽:只影响这一类组件 */
:root {
  --xh-button-h: 36px;
  --xh-dialog-max-w: 40rem;
}

/* 3. 直接写规则:放进 overrides 层,永远盖得住 */
@layer xihan.overrides {
  [data-scope='button'][data-part='root'] {
    text-transform: uppercase;
  }
}

优先选前两种。写死规则会绕开令牌体系,深色模式、密度切换这些跟着一起失效。

换品牌色

品牌色的唯一真源是 原语梯度 --xh-color-brand-50…950:语义令牌(--xh-bg-brand 等)与语气层(data-tone='brand')都从它取值。所以换品牌色要换整套原语,而不是只改 --xh-bg-brand——那只影响没写 data-tone 的缺省路径,写了 data-tone='brand' 的组件(实心按钮、开关、进度条这些)不会跟着变。

一枚种子色就够,运行时会派生整套梯度:

ts
import { brandId, createThemeController, registerBrand } from '@xihan-ui/tokens'

// 注册:从种子色派生 11 档原语,注入 [data-brand='acme'] 取值块
registerBrand('acme', '#16a34a')

// 切换:品牌是主题五维之一
const theme = createThemeController({ storageKey: 'app-theme' })
theme.setPreference({ brand: brandId('acme') })

派生只取种子的色相与彩度,明度曲线沿用基线——库里所有建立在明度上的对比度保证(实心底白字 4.5:1 这类)对任何种子色都继续成立。种子会被锚定到 600 档(实心底与强调文字的档位)。

要逐档手调,把整套梯度直接交给 registerBrand('acme', { 50: '...', ..., 950: '...' })。SSR 场景用 brandScaleCss(id, seed) 拿到取值块字符串,随首屏 HTML 下发,客户端不必再注册。

动画与进出场

进出场动画挂在 data-state 上:

css
[data-scope='dialog'][data-part='backdrop'][data-state='open'] {
  animation: xh-fade-in var(--xh-motion-duration-enter) var(--xh-motion-ease-enter);
}

[data-scope='dialog'][data-part='backdrop'][data-state='closed'] {
  animation: xh-fade-out var(--xh-motion-duration-exit) var(--xh-motion-ease-exit);
}

关闭时 DOM 不会立刻消失——进出场原语会等动画结束(或超时)才允许卸载,所以 [data-state='closed'] 的那条动画真的播得完。

升级前的形态

自定义元素在 JS 到达之前不会升级,那段时间 data-scope / data-part 都还没打上,浮层的内容会以裸文本堆在页面流里,被搜索引擎与读屏当作正文。SSR / SSG 直出时这段窗口尤其长。

undefined.css 处理这件事——按作者写的 data-xh-part 选中(那是升级前唯一存在的标记):

css
@layer xihan.components {
  :where(xh-dialog, xh-popover, xh-select /* … */):not(:defined)
    :where([data-xh-part='backdrop'], [data-xh-part='content'],
           [data-xh-part='positioner'], [data-xh-part='viewport']) {
    display: none;
  }
}

门禁

皮肤不是随便写的,几道门禁盯着它(pnpm gate):

门禁拦什么
check-token-refs皮肤引用了令牌产物里不存在的令牌名。孤儿引用不报错也不降级——整条声明在计算值阶段静默失效
check-shared-slots同一个字面量在两个以上组件里当默认值。那是一条没被命名的设计决策,应当先立语义令牌
check-disabled-contrast禁用态的前景色令牌上又叠 opacity。两种手段同时用会把对比度压到读不出字
check-overlay-strategy浮层的坐标系在机器、connect、皮肤三处不一致
check-part-wiring解剖里声明、connect 里产出、适配器却没接线的部件。皮肤为它写了规则却匹配不到任何元素
stylelint常规 CSS 规范

完全自己写皮肤

丢掉 @xihan-ui/styles,只留令牌(或连令牌一起丢),组件行为一点不受影响。你需要知道的全部接口是:

  1. data-scope + data-part——结构标识,见组件参考里每个组件的解剖;
  2. data-state / data-disabled / data-orientation / data-highlighted / data-side / data-align——状态钩子;
  3. 组件自己的语义属性,如 data-variantdata-position

data-xh-* 前缀的属性是内部标记(层栈、集合项、焦点哨兵),不承诺稳定,不要选它们。

相关

Released under The MIT License