组件

sitelo-ui 是 sitelo 的组件库。每个组件都是一个返回 HTML 字符串的函数,因此可以直接嵌进 javascript-to-html 的结构里,中间没有任何东西——没有编译器,没有运行时,没有 hydration。你写的就是最终进入 dist/ 的东西。

它随 sitelo 一起发布,入口是 sitelo/ui。

快速开始

npm install sitelo javascript-to-html

把 styles() 放进 head,在 body 里调用组件。配置到此为止:

import { body, head, html, meta, title } from 'javascript-to-html'
import { styles, container, stack, heading, text, button } from 'sitelo/ui'

export default () => html({ lang: 'zh' },
  head(
    meta({ charset: 'utf-8' }),
    meta({ name: 'viewport', content: 'width=device-width, initial-scale=1' }),
    title('我的网站'),
    styles(),
  ),
  body(
    container({ size: 'md' },
      stack({ gap: 'md' },
        heading({ level: 1 }, '你好'),
        text({ variant: 'lead' }, '一个用组件搭建的页面。'),
        button({ href: '/docs' }, '查看文档'),
      ),
    ),
  ),
)

组件名有意与它们渲染出的标签保持一致,因此其中几个——button、input、table、link、code、select、progress——会和 javascript-to-html 的元素函数重名。两边都要用时,把这个库作为命名空间导入:

import * as ui from 'sitelo/ui'

ui.card(
  ui.cardHeader({ title: '路由', subtitle: '基于文件' }),
  ui.cardBody(ui.text('src/about.ht.js 对应 /about。')),
  ui.cardFooter({ divided: true }, ui.button({ size: 'sm' }, '了解更多')),
)

调用约定

每个组件接受一个可选的 props 对象,后面跟子元素,和 javascript-to-html 的元素完全一样。组件认识的 props 会按名字取走;其余的一律作为属性落到渲染出的元素上,所以 id、data-*、aria-* 和事件属性都能用,库不必逐个列举:

button({ id: 'save', 'data-analytics': 'save-click', onclick: 'save()' }, '保存')
// <button type="button" id="save" data-analytics="save-click" onclick="save()" class="su-btn …">

如果某个 props 的值组件不认识——比如 variant: 'nonsense'——它会回退到默认值而不是抛错。一个无关紧要的拼写错误不该让构建失败。

样式

styles() 返回一个指向单个文件的 <link>,浏览器会跨页面缓存它;sitelo 的插件在 dev 里提供它,构建时把它写进产物,文件名带内容哈希,可以按 immutable 提供。传 { inline: true } 则改为返回一个装着整份样式表的 <style> 元素:传输大小约 11 kB,不用多一次请求,也不会有东西从 dist/ 里丢失:

import { styles } from 'sitelo/ui'

head(
  // 整份样式表就在页面里——一次请求都不用
  styles({ inline: true }),
)

这份样式表覆盖了库里的每一个组件,而一个站点只用其中几个。在 sitelo.config.js 里设置 pruneCss: true,就只写出构建能匹配到的规则:插件从刚写出的页面里读取 su- 类名——也从旁边的脚本里读取,以覆盖 toast 或步骤之后才加上的类名——然后丢掉选择器里提到了任何地方都没有的类名的每一条规则。依据的是输出而不是导入,所以 button({ variant: 'soft' }) 会保留 .su-btn--soft,而普通的 button() 不会。以链接引入的样式表按整个站点裁剪并得到新的哈希,每个 <link> 都会随之改写;内联的则按各自页面裁剪。十来个组件大约是 2–3 kB gzip。开发服务器仍提供完整样式表;构建看不到的标记——比如服务端区块的——需要在 keep 里列出它的类名:

export default {
  pruneCss: true,
  // 或列出构建看不到的类名;`*` 表示前缀
  // pruneCss: { keep: ['su-card', 'su-btn*'] },
}

主题

一切都由 :root 上的 CSS 自定义属性驱动:五套配色、一套间距刻度、圆角、字体和阴影。theme() 为它们写覆盖值,接受 camelCase 名称(radiusMd → --su-radius-md)、配色对象,或者直接写自定义属性:

import { styles, theme } from 'sitelo/ui'

head(
  styles(),
  // 放在 styles() 之后,这些值才会生效。
  theme({
    primary: { base: '#5b5bd6', hover: '#4a4ac4', fg: '#ffffff' },
    radiusMd: '2px',
    fontSans: '"Inter", system-ui, sans-serif',
  }, {
    dark: { primary: { base: '#8f8ff0' } },
  }),
)

深色模式会自己根据 prefers-color-scheme 解析。在任意祖先元素上把 data-theme 或 data-su-theme 设为 light 或 dark 就能覆盖它——themeToggle() 做的正是这件事:

import { styles, themeScript, themeToggle } from 'sitelo/ui'

head(
  themeScript(), // 在首次绘制前应用已保存的选择
  styles(),
)

// ……body 中的任意位置
themeToggle()

也可以一次改掉整体观感:styles({ preset: 'neumorphism' }) 会在核心样式表之后紧接着链接某个预设的样式表,每个组件都会跟着变,而 theme() 依然可以叠在上面用。它在新拟态页面上实时运行。

JavaScript,以及它有多少

大多数组件根本不需要。模态框和抽屉是 <dialog> 元素,由按钮通过 command 打开,遮罩、焦点、Escape 和点击外部都由浏览器负责。折叠面板是 <details name>。菜单是 <details>。提示气泡是纯 CSS。面板就地切换的标签页是一组单选按钮:每个标签是一个 <label>,紧跟在选中单选按钮之后的那个面板就是 CSS 显示的那一个。

确实需要脚本的有三处,而且它们各自去取:

入口文件里什么都不用加——导入语句就写在事件属性里:

<!-- rendered by alert({ dismissible: true }) -->
<button class="su-alert-dismiss"
        onclick="import('/su/alert.js').then(m=>m.dismiss(this))">
  <svg class="su-icon" …></svg>
</button>

开发时 sitelo 从 /su/(使用 --base /repo/ 时为 /repo/su/)提供这些模块,构建时只复制页面真正引用到的那几个。每个都远小于 1 kB,都要等到第一次交互才会下载;在那之前每个组件也都渲染正确:菜单自己开合,关闭按钮什么都不做。

例外是 toast(),因为页面上没有东西会替你触发它:

// src/main.js
import { toast } from 'sitelo/ui/client'

它约 3 kB,监听在 document 上而不是逐个元素绑定;没有它时,它所涉及的每个组件依然渲染正确——面板式标签页显示服务端标记为激活的那一个,菜单照常打开,关闭按钮只是什么都不做。

示例

表单会自己接好标签、id、帮助文本和错误信息:

import { card, cardBody, cardFooter, button, stack, textField, selectField } from 'sitelo/ui'

card(
  cardBody(
    stack({ gap: 'md' },
      textField({ label: '邮箱', name: 'email', type: 'email', help: '绝不外传。' }),
      textField({ label: '网站', name: 'site', startAdornment: 'https://', error: '这不是一个 URL。' }),
      selectField({ label: '套餐', name: 'plan', options: ['免费', 'Pro'], value: 'Pro' }),
    ),
  ),
  cardFooter({ divided: true }, button({ type: 'submit' }, '保存')),
)

模态框就是一个 <dialog>,任何 commandfor 指向它 id 的按钮都是它的触发器:

import { button, modal } from 'sitelo/ui'

button({ commandfor: 'confirm', command: 'show-modal' }, '删除…')

modal({
  id: 'confirm',
  title: '删除这个页面?',
  footer: button({ color: 'danger' }, '删除'),
}, '此操作无法撤销。')

标签页有两种形态——链接式,或者面板式:

// 链接式标签页:一个标签一个页面,完全不需要脚本。
tabs({ items: [
  { label: '文档', href: '/docs', active: true },
  { label: 'API', href: '/api' },
] })

// 面板式标签页:就地切换,靠一组单选按钮和 CSS。
tabs({ value: 'use', items: [
  { id: 'install', label: '安装', panel: code('npm install sitelo') },
  { id: 'use', label: '使用', panel: code("import * as ui from 'sitelo/ui'") },
] })

表格接受 columns 和 rows,当某个单元格需要的不只是一个值时,用 render 函数:

table({
  striped: true,
  columns: [
    { key: 'page', header: '页面' },
    { key: 'size', header: '大小', align: 'end' },
    { header: '状态', render: (row) => chip({ color: row.ok ? 'success' : 'danger' }, row.ok ? 'ok' : '失败') },
  ],
  rows: pages,
})

仓库里的 examples/ui 目录把每个组件都渲染在同一个页面上——这是看全整套组件最快的方式。

组件参考

按分组列出的全部导出。props 都有类型:sitelo/ui 附带 .d.ts 文件,所以编辑器在 JavaScript 里同样会补全 variant、color 和 size。

分组组件
布局container, stack, grid, divider, card, cardHeader, cardTitle, cardSubtitle, cardMedia, cardBody, cardFooter, aspectRatio
排版text, heading, link, code, inlineCode, kbd, visuallyHidden, prose
输入button, iconButton, buttonGroup, field, input, textarea, select, textField, textareaField, selectField, checkbox, radio, toggle, choiceGroup, slider, sliderField, toggleButton, toggleGroup
数据展示avatar, avatarGroup, badge, chip, tooltip, table, list, listItem, figure
反馈alert, progress, skeleton, toasts, empty
导航breadcrumbs, pagination, tabs, appBar, appBarNav, appBarSpacer, appBarActions, navLink, themeToggle
浮层modal, drawer, closeButton, menu, menuItem, menuSeparator, accordion, accordionItem, collapsible
页面区块hero, footer, siteFooter, footerColumn, footerBottom, stat, statGroup, steps, timeline, timelineItem, mockup
样式styles, stylesheet, stylesUrl, theme, themeScript

有两个名字和预期不同:开关叫 toggle,因为 switch 是保留字,不能作为导入绑定;带样式的链接同时导出为 link 和 textLink,好与 javascript-to-html 的 link 并存。table、input、select 和 progress 也有同样的备用名:dataTable、textInput、selectField、progressBar。