14 · 可复制的界面代码配方

适用:把设计要求落实成代码。示例采用原生 Web 能力与 Vue 3;生产项目优先使用已验证的项目组件与请求库。

CODE-01 · 在独立作用域使用 token

下载 tokens.css 后,按项目路径引用。它只作用于 .ui-kit,不会直接重置整个站点。

<link rel="stylesheet" href="/ui-ux/tokens.css">
<section class="ui-kit" data-palette="forest" data-mode="light">
  <h2>项目</h2>
  <p class="ui-muted">继续处理最近的项目。</p>
  <button class="ui-button ui-button-primary" type="button">创建项目</button>
</section>

上面的片段只展示样式,按钮业务需要接入。完整的静态布局参考可打开 starter.html,交互行为见实验室。迁移时一起复制资源并调整路径。

CODE-02 · 容器和内容都能缩小

.project-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
  gap: var(--ui-space-4);
}
.project-grid > * { min-inline-size: 0; }
.project-name { overflow-wrap: anywhere; }
.action-row { display: flex; flex-wrap: wrap; gap: var(--ui-space-3); }

不能用全局 overflow-x: hidden 掩盖内容溢出。应找到真正撑宽的元素,处理网格、不可换行文字或局部滚动区域。

CODE-03 · 表单错误与普通反馈分开

<label for="project-name">{{ copy.label }}</label>
<input id="project-name" v-model="name" name="name"
       :aria-invalid="fieldError ? 'true' : undefined"
       aria-describedby="name-help name-error">
<p id="name-help">{{ copy.help(rules.maxGraphemes) }}</p>
<p id="name-error">{{ fieldError }}</p>
<p id="save-status" role="status" aria-live="polite">{{ formMessage }}</p>

上面是 Vue 模板结构,字段策略、文案和保存状态由领域控制器提供;本仓库实例为 components/uiux/useProjectNameDraft.mjs。上限在领域规则中维护一次,提示与校验共同引用。按 grapheme 计数时不能直接复用原生 maxlength,具体见文案容量。

字段无效时填充对应错误,设置 aria-invalid="true" 并定位首个错误;修正后移除。保存进度与完成结果更新到 status 区域。服务器失败保留输入,不把网络错误误标成字段错误。

不要在用户仍然用输入法组词时自动提交。异步搜索可以在 compositionend 之后触发,示例:

function onSearchInput(event) {
  if (!event.isComposing) scheduleSearch(event.target.value);
}
// compositionend 时再调用 scheduleSearch;函数由项目实现防抖。

CODE-04 · 只应用当前请求的结果

以下是 Vue 3 的只读搜索示例。它处理取消和响应乱序,不处理缓存、认证恢复和生产监控。

import { onUnmounted, ref } from 'vue';
const rows = ref([]), pending = ref(false), error = ref('');
let sequence = 0, controller;

async function search(query) {
  const current = ++sequence;
  controller?.abort();
  controller = new AbortController();
  pending.value = true;
  error.value = '';
  try {
    const response = await fetch(`/api/projects?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const result = await response.json(); // 真实项目还应校验响应结构。
    if (current === sequence) rows.value = result.items;
  } catch (cause) {
    if (current === sequence && cause.name !== 'AbortError') {
      error.value = '未能更新项目,请重试'; // 已有 rows 不清空。
    }
  } finally {
    if (current === sequence) pending.value = false;
  }
}
onUnmounted(() => { sequence++; controller?.abort(); });

写操作取消后服务端可能已执行;需要查询与幂等策略,不能把上面的读请求模式原样用于付款。见状态与恢复中的 ST-04。

CODE-05 · 用原生 dialog 提供模态基础

<button id="open-confirm" type="button">查看操作说明</button>
<dialog id="confirm-dialog" aria-labelledby="confirm-title">
  <h2 id="confirm-title">确认操作范围</h2>
  <p>这里显示具体对象与影响。</p>
  <form method="dialog">
    <button autofocus value="cancel">返回</button>
  </form>
</dialog>
<script>
  const trigger = document.querySelector('#open-confirm');
  const dialog = document.querySelector('#confirm-dialog');
  trigger.addEventListener('click', () => dialog.showModal());
  dialog.addEventListener('close', () => trigger.focus());
</script>

示例只展示打开、关闭和焦点恢复,没有执行业务操作。在组件中应使用组件引用,并处理触发元素被移除等情况。showModal() 和 show() 的行为不同,参见 MDN dialog。

CODE-06 · 图片预留尺寸,失败不挡任务

<img src="/ui-ux/empty-projects.svg" width="480" height="240"
     alt="" loading="lazy" decoding="async">
<h2>开始你的第一个项目</h2>
<p>把想法和进展放在一起。</p>

这里图片是装饰,标题独立表达含义。首屏关键图片是否延迟加载需要另行判断。业务图片缺失时应有对应的回退策略,不能所有失败都换成同一张无关占位图。

复制之后的检查

检查变量命名是否映射到项目 token、静态路径是否有效、事件是否清理、状态是否可复现、键盘能否完成操作。示例只保证它所解释的范围,不替代项目的错误处理与测试。