地理信息 · 新资源

Esri Calcite 设计系统.

Calcite 的实用参考,聚焦于空间工具:语义化界面表面和蓝色操作、紧凑的检查面板、真实的输入 API,以及完整的本地站点审查示例。

OpenDesign 指南8 个章节48 个参考条目原始应用指南
01

Esri Calcite 设计系统界面预览

从列表或示意图中选择站点,筛选审核状态,并验证一条按站点保存、直到重新加载前一直保留的备注。

查看虚构站点并保留本地备注打开完整示例
基于有文档记录的视觉参考,对 HTML/CSS/JavaScript 进行原创演绎。此插图不是 ArcGIS 地图;未捆绑 Esri 运行时或字体。· 实现参考
02

Esri Calcite 设计系统设计参考

Calcite 设计令牌 4.1.0 和当前组件 API,并与 Components 5.1.2 一并核对。令牌包和组件包的版本号彼此独立。内嵌示例是对 HTML/CSS 的原创演绎;它不会重新分发 Calcite 运行时,也不声称具备原生行为。

浅色主题的界面表面、文本与交互

来源
名称 / 规则值 / 配置用途
--calcite-color-surface-1#f7f7f7 用于说明令牌所代表角色的语义参考;选择状态和字段边框需要不同的对比度。
--calcite-color-surface-2#ffffff 用于说明令牌所代表角色的语义参考;选择状态和字段边框需要不同的对比度。
--calcite-color-surface-3#f2f2f2 用于说明令牌所代表角色的语义参考;选择状态和字段边框需要不同的对比度。
--calcite-color-surface-4#ebebeb 用于说明令牌所代表角色的语义参考;选择状态和字段边框需要不同的对比度。
--calcite-color-surface-highlight#d6efff 用于说明令牌所代表角色的语义参考;选择状态和字段边框需要不同的对比度。
--calcite-color-text-1#141414 用于说明令牌所代表角色的语义参考;选择状态和字段边框需要不同的对比度。
--calcite-color-text-2#4a4a4a 用于说明令牌所代表角色的语义参考;选择状态和字段边框需要不同的对比度。
--calcite-color-text-3#6b6b6b 用于说明令牌所代表角色的语义参考;选择状态和字段边框需要不同的对比度。
--calcite-color-text-inverse#ffffff 用于说明令牌所代表角色的语义参考;选择状态和字段边框需要不同的对比度。
--calcite-color-text-link#00619b 用于说明令牌所代表角色的语义参考;选择状态和字段边框需要不同的对比度。
--calcite-color-border-1#d4d4d4 用于说明令牌所代表角色的语义参考;选择状态和字段边框需要不同的对比度。
--calcite-color-border-2#dedede 用于说明令牌所代表角色的语义参考;选择状态和字段边框需要不同的对比度。
--calcite-color-border-3#ebebeb 用于说明令牌所代表角色的语义参考;选择状态和字段边框需要不同的对比度。
--calcite-color-border-input#949494 用于说明令牌所代表角色的语义参考;选择状态和字段边框需要不同的对比度。
--calcite-color-brand#007ac2 用于说明令牌所代表角色的语义参考;选择状态和字段边框需要不同的对比度。
--calcite-color-brand-hover#00619b 用于说明令牌所代表角色的语义参考;选择状态和字段边框需要不同的对比度。
--calcite-color-brand-press#004874 用于说明令牌所代表角色的语义参考;选择状态和字段边框需要不同的对比度。

当前间距比例

来源
名称 / 规则值 / 配置用途
--calcite-space-none0当前间距令牌系列。以 rem 为单位的步进值遵循根字号;避免与已弃用的间距别名混用。
--calcite-space-px1px当前间距令牌系列。以 rem 为单位的步进值遵循根字号;避免与已弃用的间距别名混用。
--calcite-space-base2px当前间距令牌系列。以 rem 为单位的步进值遵循根字号;避免与已弃用的间距别名混用。
--calcite-space-2xs0.25rem当前间距令牌系列。以 rem 为单位的步进值遵循根字号;避免与已弃用的间距别名混用。
--calcite-space-xs0.375rem当前间距令牌系列。以 rem 为单位的步进值遵循根字号;避免与已弃用的间距别名混用。
--calcite-space-sm0.5rem当前间距令牌系列。以 rem 为单位的步进值遵循根字号;避免与已弃用的间距别名混用。
--calcite-space-sm-plus0.625rem当前间距令牌系列。以 rem 为单位的步进值遵循根字号;避免与已弃用的间距别名混用。
--calcite-space-md0.75rem当前间距令牌系列。以 rem 为单位的步进值遵循根字号;避免与已弃用的间距别名混用。
--calcite-space-md-plus0.875rem当前间距令牌系列。以 rem 为单位的步进值遵循根字号;避免与已弃用的间距别名混用。
--calcite-space-lg1rem当前间距令牌系列。以 rem 为单位的步进值遵循根字号;避免与已弃用的间距别名混用。
--calcite-space-xl1.25rem当前间距令牌系列。以 rem 为单位的步进值遵循根字号;避免与已弃用的间距别名混用。
--calcite-space-2xl1.5rem当前间距令牌系列。以 rem 为单位的步进值遵循根字号;避免与已弃用的间距别名混用。
--calcite-space-3xl2rem当前间距令牌系列。以 rem 为单位的步进值遵循根字号;避免与已弃用的间距别名混用。
--calcite-space-4xl2.75rem当前间距令牌系列。以 rem 为单位的步进值遵循根字号;避免与已弃用的间距别名混用。

字体与圆角比例

来源
名称 / 规则值 / 配置用途
--calcite-corner-radius-none0已发布的数值,而非从截图推测的测量结果。
--calcite-corner-radius-xs2px已发布的数值,而非从截图推测的测量结果。
--calcite-corner-radius-sm4px已发布的数值,而非从截图推测的测量结果。
--calcite-corner-radius-pill9999px已发布的数值,而非从截图推测的测量结果。
--calcite-font-size-xs10px已发布的数值,而非从截图推测的测量结果。
--calcite-font-size-sm12px已发布的数值,而非从截图推测的测量结果。
--calcite-font-size14px已发布的数值,而非从截图推测的测量结果。
--calcite-font-size-md16px已发布的数值,而非从截图推测的测量结果。
--calcite-font-size-lg18px已发布的数值,而非从截图推测的测量结果。
--calcite-font-size-xl20px已发布的数值,而非从截图推测的测量结果。
--calcite-font-size-2xl24px已发布的数值,而非从截图推测的测量结果。

输入组件 API

来源
名称 / 规则值 / 配置用途
scales / m / l; default m有意识地调整组件比例;这与更改页面缩放不同。
statusidle / valid / invalid通过提示消息说明字段是否有效,而不只依赖颜色。
labelTextstring当前输入组件 API 上可见的标签文本。
validationMessagestring在控件旁说明无效输入。
calciteInputInputevent在用户输入时读取 event.target.value。
setFocus()Promise<void>应用验证出错后,将焦点移入原生组件。
03

Esri Calcite 设计系统完整指南

实用指南,介绍如何为真实界面选择、应用和评审此系统。

DESIGN-esri-calcite.md

完整指导、实现起点和源代码链接。

下载指南

Esri Calcite 设计系统工作区与面板架构

让对象上下文保持可见

空间类任务通常至少包含两个上下文:正在检查的区域和选中的对象。此示例在原创示意图和检查器旁放置了一个简易站点列表。选择列表行或图钉时,都会设置同一个稳定的站点 ID,因此名称、状态和备注不会意外关联到不同对象。

摘自本 OpenDesign 指南

将插图与真实地图区分开

此 SVG 用于说明列表与空间界面表面之间的关系。它不包含投影、坐标、图层、缩放、地理编码或路径规划功能。可见说明标明这是示意图,且不按比例绘制。生产环境中的 ArcGIS 视图需要单独集成 SDK、数据和无障碍功能;仅有 Calcite 外壳并不具备这些能力。

Esri Calcite 设计系统语义颜色角色

用界面表面划分工具区域,避免使用厚重卡片

参考设计区分 surface-1 到 surface-4,并将 surface-highlight 专用于强调。示例在克制的灰白画布上使用白色检查器和浅蓝色选中行。细而中性的边框用于标示面板边界,避免给每个字段都加上浮动卡片。

让蓝色始终代表一致的交互含义

品牌色、悬停色和按下色构成一组逐渐加深的色阶:从 #007ac2 到 #00619b,再到 #004874。选中图钉和主要保存操作分别使用这一色系表达不同的交互角色。书面审核状态和备注验证消息即使不依赖颜色也清晰易读。

Esri Calcite 设计系统字体与信息密度

保留精简的信息层级

已发布的绝对字号参考包括 12px 小号文本、14px 基础字号、16px 中号文本和 20px 超大字号。示例将 20px 工作区标题与 16px 面板标题和 14px 控件搭配使用。元数据字号更小,但选中对象不会只在元数据中标识。

区分回退字体与品牌字体

此独立示例使用 Arial 和系统字体回退,不包含 Esri 品牌字体,也不会将回退字体标注为 Avenir。经授权的原生集成应遵循所固定 Calcite 版本的资源和主题设置。更改字体可能会影响换行和控件宽度,因此请使用实际部署的资源集进行测试。

Esri Calcite 设计系统间距令牌与响应式面板

选择当前令牌名称

此参考采用当前的 --calcite-space-* 比例,其中最小间距步进值以像素为单位,较大的间距则以 rem 为单位。实现原生主题时,请始终一致地使用这些命名角色。此示例的应用布局采用明确的 CSS 值,因此无需导入受限软件包也能运行。

先调整检查器布局,再压缩字段

Esri Calcite 设计系统选择与筛选行为

使用单一选中标识符

列表和示意图通过 A-01、B-02 和 C-03 共享选中状态,而非依赖当前数组位置。筛选至 Checked 后,符合条件的站点会被选中,检查器也会随之更新。列表按钮和对应图钉都会公开按下状态。

将已保存备注与未保存草稿分开

已保存的备注存储在一个以站点 ID 为键的 Map 中。返回该站点时,备注会恢复;切换站点而不保存,则未保存的文本会被替换。辅助说明明确指出了数据的本地保留期限。如果丢失草稿会造成重大损失,生产环境中的编辑器应增加未保存更改保护。

Esri Calcite 设计系统输入验证与恢复

使用 Calcite 时,请采用实际的原生输入 API

原生示例使用 labelText、value、status 和 validationMessage。calciteInputInput 事件可清除过时的错误;应用规则验证失败后,setFocus() 可将焦点返回组件。这些都是公开的 API 名称,不需要深入 shadow root 查找内部输入框。

不要将本地确认误认为服务端持久化

独立预览要求输入至少 10 个去除首尾空格的字符,且最多 240 个字符。确认信息会指出站点并明确说明备注保存在本地。重新加载页面会清除备注。联网版本还需要处理等待、失败、重试和冲突等状态;本地保存提示并不意味着具备这些能力。

  1. 尝试保存空白备注或过短的备注,并查看错误提示。
  2. 输入一条较长的备注并保存,然后选择其他站点,再返回原站点。
  3. 重新加载页面,确认示例不会承诺持久化保存。

Esri Calcite 设计系统无障碍空间信息替代方案

为每个图钉提供对应的列表项

每个可交互的图钉在站点列表中都有对应的文本按钮。因此,示意地图并非选择对象的唯一方式。其 SVG 带有简明描述,各控件也有自己的名称和清晰可见的焦点轮廓。真实地图所需的内容远不止复制示意图的替代文本。

在字段旁显示验证信息

文本区域通过 aria-describedby 同时关联辅助说明和错误信息。验证失败时,字段会获得 aria-invalid 属性和焦点;编辑后,过时的无效状态会被清除。确认信息使用状态区域。键盘用户无需操作绘制的地理图形,也可以选择站点、输入备注并保存。

Esri Calcite 设计系统版本与集成边界

固定软件包版本并检查许可要求

Components 5.1.2 和 design tokens 4.1.0 是不同的软件包。当前许可页面说明,使用这些软件包需要 ArcGIS Online、Enterprise 或 Location Platform 账户。不要将对旧版开源发行版的假设套用到当前软件包上。以下原生代码示例适用于满足这些要求的项目。

  • Components:5.1.2。
  • 设计令牌参考:4.1.0。
  • 预览:独立的 HTML、CSS 和 JavaScript;不包含 Esri 运行时。

仅注册实际使用的组件

Esri Calcite 设计系统组件示例

独立站点检查示例

本预览使用的完整独立 HTML、CSS 和 JavaScript。将其保存为 index.html 并在浏览器中打开。

html
<!doctype html>
<html lang="en">
  <meta charset="utf-8" /><meta
    name="viewport"
    content="width=device-width,initial-scale=1"
  /><title>Site inspection panel</title
  ><style>
    * {
      box-sizing: border-box;
    }
    body {
      margin: 0;
      font:
        14px/1.5 Arial,
        sans-serif;
      color: #141414;
      background: #f7f7f7;
    }
    button,
    input,
    select,
    textarea {
      font: inherit;
    }
    button {
      cursor: pointer;
    }
    button:focus-visible,
    input:focus-visible,
    select:focus-visible,
    textarea:focus-visible {
      outline: 2px solid #007ac2;
      outline-offset: 3px;
    }
    header {
      padding: 20px 24px;
      background: #fff;
      border-bottom: 1px solid #d4d4d4;
      display: flex;
      justify-content: space-between;
      gap: 16px;
      align-items: center;
    }
    h1 {
      font-size: 20px;
      margin: 0;
    }
    header p {
      margin: 2px 0 0;
      color: #6b6b6b;
      font-size: 12px;
    }
    .badge {
      background: #d6efff;
      color: #004874;
      padding: 4px 8px;
      font-size: 12px;
    }
    .layout {
      display: grid;
      grid-template-columns: 240px minmax(0, 1fr) 280px;
      min-height: 510px;
    }
    .sites,
    .inspector {
      background: #fff;
      padding: 20px;
      border-right: 1px solid #d4d4d4;
    }
    .inspector {
      border-right: 0;
      border-left: 1px solid #d4d4d4;
    }
    h2 {
      font-size: 16px;
      margin: 0 0 16px;
    }
    label {
      display: block;
      font-weight: 600;
      margin: 12px 0 6px;
    }
    select,
    textarea {
      width: 100%;
      border: 1px solid #949494;
      background: #fff;
      padding: 8px;
      color: inherit;
    }
    textarea {
      resize: vertical;
      min-height: 96px;
    }
    .site {
      display: block;
      width: 100%;
      text-align: left;
      background: #fff;
      border: 1px solid #dedede;
      padding: 12px;
      margin-top: 8px;
    }
    .site[aria-pressed="true"] {
      border-left: 4px solid #007ac2;
      background: #d6efff;
    }
    .site small {
      display: block;
      color: #4a4a4a;
      margin-top: 3px;
    }
    .map {
      position: relative;
      overflow: hidden;
      min-height: 360px;
      background: #ebebe5;
    }
    .map svg {
      width: 100%;
      height: 100%;
      position: absolute;
      inset: 0;
    }
    .map-caption {
      position: absolute;
      bottom: 12px;
      left: 12px;
      padding: 4px 8px;
      background: #fff;
      font-size: 12px;
    }
    .pin {
      position: absolute;
      width: 36px;
      height: 36px;
      border-radius: 50%;
      border: 3px solid #fff;
      background: #007ac2;
      color: #fff;
      font-weight: bold;
      box-shadow: 0 2px 8px #0004;
    }
    .pin[aria-pressed="true"] {
      background: #004874;
      outline: 3px solid #007ac2;
      outline-offset: 3px;
    }
    .pin[hidden] {
      display: none;
    }
    dl {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 8px;
      font-size: 12px;
    }
    dt {
      color: #6b6b6b;
    }
    dd {
      margin: 0;
      text-align: right;
    }
    .primary {
      background: #007ac2;
      color: white;
      border: 0;
      width: 100%;
      padding: 10px;
      margin-top: 12px;
    }
    .primary:hover {
      background: #00619b;
    }
    .error {
      color: #a11b1b;
      font-size: 12px;
    }
    .saved {
      color: #237000;
      font-size: 12px;
    }
    footer {
      padding: 16px 24px;
      border-top: 1px solid #d4d4d4;
      color: #6b6b6b;
      font-size: 12px;
      background: #fff;
    }
    @media (max-width: 1000px) {
      .layout {
        grid-template-columns: 210px minmax(0, 1fr);
      }
      .inspector {
        grid-column: 1/-1;
        border-left: 0;
        border-top: 1px solid #d4d4d4;
      }
      .inspector form {
        max-width: 640px;
      }
    }
    @media (max-width: 550px) {
      header {
        align-items: flex-start;
        flex-direction: column;
      }
      .layout {
        display: flex;
        flex-direction: column;
      }
      .sites {
        border-right: 0;
      }
      .map {
        height: 300px;
        min-height: 300px;
      }
      .inspector {
        padding: 20px;
      }
      .site {
        display: inline-block;
        width: 100%;
      }
    }
  </style>
  <body>
    <header>
      <div>
        <h1>Field inspection workspace</h1>
        <p>Original Calcite-inspired layout study · fictional site data</p>
      </div>
      <span class="badge">Local notes only</span>
    </header>
    <main class="layout">
      <aside class="sites">
        <h2>Inspection sites</h2>
        <label for="filter">Show sites</label
        ><select id="filter">
          <option value="all">All sites</option>
          <option value="review">Needs review</option>
          <option value="checked">Checked</option>
        </select>
        <div id="site-list"></div>
        <p id="count" aria-live="polite"></p>
      </aside>
      <section class="map" aria-label="Illustrated site plan">
        <svg
          viewBox="0 0 700 510"
          preserveAspectRatio="xMidYMid slice"
          role="img"
          aria-label="Original schematic with a river, roads and three inspection sites; not a geographic map"
        >
          <rect width="700" height="510" fill="#e4e8db" />
          <path
            d="M480 -50Q230 110 440 260T360 570"
            fill="none"
            stroke="#b6dbe9"
            stroke-width="100"
          />
          <path
            d="M-20 150L720 280M160 -20L290 530M-20 400L720 80"
            stroke="#fff"
            stroke-width="22"
          />
          <path
            d="M-20 150L720 280M160 -20L290 530M-20 400L720 80"
            stroke="#c8c8bf"
            stroke-width="2"
          />
          <g fill="#c4cfb5">
            <rect x="50" y="50" width="100" height="70" />
            <rect x="510" y="340" width="130" height="80" />
            <rect x="80" y="260" width="70" height="60" />
          </g>
        </svg>
        <div id="pins"></div>
        <span class="map-caption">Schematic illustration · not to scale</span>
      </section>
      <aside class="inspector">
        <h2 id="site-title">North access</h2>
        <dl>
          <dt>Site ID</dt>
          <dd id="site-id"></dd>
          <dt>Review state</dt>
          <dd id="site-state"></dd>
        </dl>
        <form id="note-form" novalidate>
          <label for="note">Inspection note</label
          ><textarea
            id="note"
            aria-describedby="note-help error"
            maxlength="240"
          ></textarea>
          <p id="note-help">
            Add 10–240 characters. Notes stay in this page until reload.
          </p>
          <p id="error" class="error" hidden>
            Write at least 10 characters before saving.
          </p>
          <button class="primary" type="submit">Save local note</button>
          <p id="saved" class="saved" role="status"></p>
        </form>
      </aside>
    </main>
    <footer>
      Independent HTML/CSS interpretation by OpenDesign. No Calcite runtime,
      Esri fonts, ArcGIS map or service is loaded.
    </footer>
    <script>
      const sites = [
        { id: "A-01", name: "North access", state: "review", x: 29, y: 24 },
        { id: "B-02", name: "River crossing", state: "review", x: 62, y: 48 },
        { id: "C-03", name: "South garden", state: "checked", x: 34, y: 76 },
      ];
      let selected = "A-01";
      const notes = new Map(),
        list = document.querySelector("#site-list"),
        pins = document.querySelector("#pins"),
        filter = document.querySelector("#filter"),
        note = document.querySelector("#note"),
        error = document.querySelector("#error"),
        saved = document.querySelector("#saved");
      function render() {
        const visible = sites.filter(
          (s) => filter.value === "all" || s.state === filter.value,
        );
        if (!visible.some((s) => s.id === selected)) selected = visible[0]?.id;
        list.replaceChildren();
        pins.replaceChildren();
        for (const s of visible) {
          const b = document.createElement("button");
          b.type = "button";
          b.className = "site";
          b.setAttribute("aria-pressed", String(s.id === selected));
          b.textContent = s.name;
          const small = document.createElement("small");
          small.textContent =
            s.id + " · " + (s.state === "review" ? "Needs review" : "Checked");
          b.append(small);
          b.onclick = () => {
            selected = s.id;
            render();
          };
          list.append(b);
          const pin = document.createElement("button");
          pin.type = "button";
          pin.className = "pin";
          pin.style.left = s.x + "%";
          pin.style.top = s.y + "%";
          pin.textContent = s.id[0];
          pin.setAttribute("aria-label", "Select " + s.name);
          pin.setAttribute("aria-pressed", String(s.id === selected));
          pin.onclick = b.onclick;
          pins.append(pin);
        }
        const s = sites.find((s) => s.id === selected);
        document.querySelector("#count").textContent =
          visible.length + " sites shown";
        document.querySelector("#site-title").textContent = s.name;
        document.querySelector("#site-id").textContent = s.id;
        document.querySelector("#site-state").textContent =
          s.state === "review" ? "Needs review" : "Checked";
        note.value = notes.get(s.id) || "";
        error.hidden = true;
        note.removeAttribute("aria-invalid");
        saved.textContent = "";
      }
      filter.onchange = render;
      note.oninput = () => {
        error.hidden = true;
        note.removeAttribute("aria-invalid");
        saved.textContent = "";
      };
      document.querySelector("#note-form").onsubmit = (e) => {
        e.preventDefault();
        const value = note.value.trim();
        if (value.length < 10) {
          error.hidden = false;
          note.setAttribute("aria-invalid", "true");
          note.focus();
          return;
        }
        notes.set(selected, value);
        saved.textContent = "Note saved locally for " + selected + ".";
      };
      render();
    </script>
  </body>
</html>

代码参考

  • 原始示意图和应用行为。不包含地图 SDK、远程数据或 Esri 运行时。

Calcite 原生输入替代方案

在 Vite 原生项目中替换 src/main.js;该项目的 index.html 中应包含 <div id="app"></div>。

js
import "@esri/calcite-components/components/calcite-input";
import "@esri/calcite-components/components/calcite-button";

document.querySelector("#app").innerHTML = `
  <calcite-input id="inspection-note" label-text="Inspection note"
    validation-message="Enter at least ten characters"></calcite-input>
  <calcite-button id="save-note">Validate note</calcite-button>
  <p id="result" role="status"></p>`;
const field = document.querySelector("#inspection-note");
const result = document.querySelector("#result");
field.addEventListener("calciteInputInput", () => {
  field.status = "idle";
  result.textContent = "";
});
document.querySelector("#save-note").addEventListener("click", async () => {
  if (field.value.trim().length < 10) {
    field.status = "invalid";
    await field.setFocus();
    return;
  }
  field.status = "valid";
  result.textContent = "The note passes the local rule. Nothing was sent.";
});

代码参考

  • 查看当前许可要求后,安装 @esri/[email protected]。此替代方案已通过 API 检查;上方的独立预览则是在浏览器中测试过的示例。
04

Esri Calcite 设计系统 AI Agent 设置

实现起点

根据当前许可条款,使用原生组件软件包需要符合条件的 ArcGIS 账户。自行托管组件资源时,请查看资源路径要求。

sh
# Independent specimen: save the complete HTML example as index.html.
# Native alternative for an appropriately licensed project:
npm create vite@latest calcite-note -- --template vanilla
cd calcite-note
npm install
npm install @esri/[email protected]
# Replace src/main.js with the native input example.
npm run dev

适用于你项目的提示词

Agent 提示词
使用 Calcite 参考资料构建一个专注于空间信息检查的工作区。确保列表和检查器中的所选对象 ID 保持一致,明确展示各项状态,并区分本地备注与服务端持久化。可以从独立实现开始,也可以明确选择符合许可要求的原生组件方案。不要将原始示意图描述为 ArcGIS 地图。
05

Esri Calcite 设计系统来源与适用范围

OpenDesign 应用指南。图示示例为独立实现,使用虚构数据。当前使用 Calcite 受 Esri 许可条款约束,并需要符合条件的 ArcGIS 账户;原生集成示例适用于符合许可要求的项目,并非本预览所用的运行时。