@canonical/react-ds-global-form

中文文档
0.45.0发布于 2026/10/23LGPL-3.0TypeScriptES Module构建来源已验证近 30 天下载 7734

Form components for the Pragma design system. This package provides a field system built on react-hook-form with two core patterns: a field switch for rendering different input types and middleware composition for extending field behavior.

原始使用文档
文档目录17 节
  1. Prerequisites
  2. Installation
  3. How component CSS reaches the page
  4. Every stylesheet is in ds.components.global
  5. Components own the box of the natives they render
  6. Transitions read the motion tokens
  7. Dependencies
  8. Usage
  9. Form modes
  10. Field Switch Pattern
  11. Custom Fields
  12. Standalone Inputs
  13. Middleware Pattern
  14. Built-in Middleware
  15. Conditional Display
  16. Storybook
  17. Component Specifications

@canonical/react-ds-global-form

Form components for the Pragma design system. This package provides a field system built on react-hook-form with two core patterns: a field switch for rendering different input types and middleware composition for extending field behavior.

Prerequisites

  • React 19 or higher

Installation

bun add @canonical/react-ds-global-form @canonical/styles

Import the design system's styles first in your application's entry, before anything that imports a component:

import "@canonical/styles";

Or, from a stylesheet: @import url("@canonical/styles");.

Each component brings its own stylesheet when you import it. The package stylesheet (field layout, input chrome, form tokens) is not imported by any component, so import it right after the styles:

import "@canonical/react-ds-global-form/dist/esm/index.css";

How component CSS reaches the page

This package puts CSS on the page two ways. dist/esm/index.css — the file the snippet above imports — is the package-level stylesheet: the --form-* token block, the field grid, the shared input chrome, and density.css, which sizes every control to the context and density cell on the root. Each component module then imports its own stylesheet (import "./styles.css"), so importing a component is what puts that component's CSS on the page; a bundler collects those imports into the application's CSS. Nothing here is injected at runtime, and a component you never import ships no CSS.

The consequence is that the order a bundler happens to emit these sheets in is not something you can rely on, which is what the cascade layer below is for.

Every stylesheet is in ds.components.global

Every stylesheet under src/ — the package entry, density.css, the 29 component sheets and the density docs example, 32 in all — is wrapped in one cascade layer:

@layer ds.components.global {
  .ds.field-label {
    /* … */
  }
}

@canonical/styles declares the order of every layer in one statement, and ds.components.global sits near the top of it. Two things follow.

ds.components.app, one layer higher, is the application tiers' — @canonical/react-ds-app-lxd and its siblings. None of them is wrapped yet (at the time of writing, no stylesheet under packages/react/ds-app-* carries a layer at all); wrapping them is being done package by package alongside this one. Today an app tier still beats this package for the opposite reason — it is unlayered, and unlayered beats layered. What the layer guarantees, once those packages land, is that an app tier's rule for a field or an input this package also styles wins by cascade layer rather than by whichever bundle the loader emitted last.

The parent layer ds.components sits above both tiers, not between them: a rule written directly into it lands in that layer's implicit final sublayer, which outranks every named sublayer under it. So nothing pragma ships is written directly into ds.components — the layout presets in src/index.css used to be, in a lone @layer ds.components block, and they are in this package's own wrap now like everything else.

An application's own unlayered CSS now beats every rule in this package, whatever the selectors on either side, because unlayered author rules outrank every layered one. That is CSS working as designed, and it is the deliberate escape hatch: an application that needs to override a form control writes a plain rule and it wins. An application that does not want to win by accident puts its CSS in @layer app.

Rule for contributors: every stylesheet under src/ opens with that wrapper. @keyframes and the package's own :root token defaults go inside it; @property and @font-face registrations stay above the block. That last one is a convention, not something the cascade forces: measured in Chromium 151, two registrations sharing a name are sorted exactly like style rules — the one in the higher layer wins whatever the source order, and an unlayered one beats a layered one — for @property, @font-face and @keyframes alike. Keeping registrations above the block is what makes them the document-wide authority they read as, and puts them where someone looking up what a name is registered as will find them. An @import stays above the block too — an import is only valid before other rules — and it takes a layer(ds.components.global) keyword only when the sheet it names is not itself wrapped; src/index.css imports density.css bare, because density.css declares the layer itself and the keyword would nest that declaration into ds.components.global.ds.components.global, a sublayer that loses to index.css's own rules. Never reach for !important to win a fight — an important declaration inverts the layer order and cannot be arbitrated by layers at all. The @canonical/styles README's "Cascade layers" section is the reference for the full order and for what is deliberately left unlayered. The one exemption is .storybook/styles.css, which is deliberately outside src/: it is the Storybook harness rather than part of the package — never published (files: ["dist"]), there to pull this package's CSS into the preview page and add one .rtl utility — and staying unlayered is what lets it override the preview.

Components own the box of the natives they render

This package renders more native elements than any other: <input> in a dozen types, <select>, <textarea>, <label>, <legend>, <fieldset>, <button>. A component that renders a native element is responsible for that element's box — its margin, its width, its min-width, its box-sizing — and for ::placeholder where it renders a text input. Anything a component leaves undeclared is filled in by whatever else the host page loads, and on a page that also runs another framework that is a visible bug rather than a default. Declaring the box is being done as its own change; until it lands, treat "the control looks right on our own page" as a weaker guarantee than it sounds.

Nothing in this package portals: no createPortal, no appendChild, no <dialog>. The two surfaces that escape their container — the combobox list (ComboboxInput/common/List/List.tsx:42) and the colour picker's swatch panel (ColorInput/ColorInput.tsx:198) — use the Popover API, which promotes an element to the top layer for painting but leaves it where it is in the DOM. So they stay inside the .ds subtree their caller marks, and the element-level layers @canonical/styles scopes to that subtree still reach them.

Transitions read the motion tokens

Every transition in this package takes its duration from --motion-duration-fast, the fast step of the three duration tokens @canonical/styles declares, and never from a literal. That is not a tidiness rule. Under prefers-reduced-motion: reduce the styles package sets all three duration tokens to 0s, and that zeroing is the whole of pragma's reduced-motion mechanism: a component's own transition declaration keeps its place in the cascade and simply resolves to no time at all. A hard-coded duration is invisible to it and keeps animating for a reader who asked their system not to. This is the code standard cs:css.properties.values ("design tokens over raw values") with teeth on it.

Rule for contributors: no literal duration in a transition or an animation; a new one reads a token, and a bare 0s is the one exception, because it already says "no motion". The tokens themselves live in @canonical/styles (motion.css); if a control needs a step the three do not offer, the step is added there rather than written into a component. Easings are the exception, and a deliberate one: they are still the literal ease here rather than var(--motion-easing-standard), because that token is ease-out and swapping it is a second change to how the controls feel — it rides on PRA-153 with the missing sub-fast duration step.

Dependencies

The form system builds on two key libraries:

  • react-hook-form - Form state management, validation, and field registration. The Field component uses useFormContext internally, so forms must be wrapped in a FormProvider.
  • downshift - Powers the combobox field with accessible autocomplete behavior.

Usage

Wrap your form in a Form component and use Field for inputs:

import { Form, Field } from "@canonical/react-ds-global-form";

function ContactForm() {
  const onSubmit = (data) => console.log(data);

  return (
    <Form onSubmit={onSubmit}>
      <Field
        name="email"
        inputType="email"
        label="Email address"
        description="We'll never share your email."
      />
      <Field
        name="message"
        inputType="textarea"
        label="Message"
      />
      <button type="submit">Send</button>
    </Form>
  );
}

Form modes

Form wraps its children in a react-hook-form FormProvider and works in two modes:

  • Internal mode (above) — pass onSubmit, and optionally defaultValues and a validation mode; Form creates the useForm instance for you. Best for a self-contained form.

  • External mode — create the useForm instance yourself and pass it as methods. Because you own it, you can read formState (e.g. isSubmitting) and call its methods (reset, setValue, watch, …). Use this for async submits, shared state, or multi-step forms.

    import { useForm } from "react-hook-form";
    
    const methods = useForm({ mode: "onBlur", defaultValues: { email: "" } });
    const { reset, formState: { isSubmitting } } = methods;
    
    <Form methods={methods} onSubmit={async (data) => { await save(data); reset(); }}>
      <Field name="email" inputType="email" label="Email" />
      <button type="submit" disabled={isSubmitting}>Send</button>
    </Form>;
    

    When you pass methods, Form's own defaultValues/mode props are ignored — configure those on your useForm call.

This library is a thin layer over react-hook-form (^7.71): validation rules (registerProps), formState, submission, field arrays, and schema resolvers are all RHF's API. See the Getting Started guide in Storybook for a full walkthrough with runnable examples, and the react-hook-form docs for the complete surface.

Field Switch Pattern

The Field component uses inputType to select the appropriate input component:

inputTypeComponentDescription
text, email, password, number, tel, urlTextStandard text inputs
textareaTextareaMulti-line text
checkboxCheckboxBoolean toggle
rangeRangeSlider input
selectSelectDropdown selection
simple-choicesSimpleChoicesRadio buttons or checkboxes
comboboxComboboxSearchable dropdown
hiddenHiddenHidden input
customYour componentPass via CustomComponent prop

All fields are wrapped with withWrapper, which provides form registration, labels, descriptions, error display, and middleware support.

Custom Fields

For field types not covered by the built-ins, use inputType="custom":

import { Field } from "@canonical/react-ds-global-form";
import { MyColorPicker } from "./MyColorPicker";

<Field
  name="brandColor"
  inputType="custom"
  CustomComponent={MyColorPicker}
  label="Brand Color"
/>

Custom components must be wrapped with withWrapper. Custom components must satisfy the InputProps type and integrate with react-hook-form via useFormContext.

Standalone Inputs

Every input the Field pattern composes is also exported on its own: TextInput, SelectInput, CheckboxInput, SwitchInput, RadioInput, NumberInput, PasswordInput, RangeInput, RatingInput, DateInput, TimeInput, DateTimeInput, ColorInput, ComboboxInput, PhoneInput, FileUploadInput, TextareaInput and HiddenInput, each with its props type. They are presentational: no <Form>, no react-hook-form context and no label, so a control outside a form keeps the same input chrome. Name it with aria-label or a <label> of your own.

import { SelectInput } from "@canonical/react-ds-global-form";

<SelectInput
  aria-label="Language"
  value={locale}
  onChange={(event) => setLocale(event.target.value)}
  options={[
    { value: "en", label: "English", lang: "en" },
    { value: "fr", label: "français", lang: "fr" },
  ]}
/>

An option's optional lang marks a label written in another language, so assistive technology pronounces it correctly.

Middleware Pattern

Middleware are higher-order components that wrap fields to add functionality. They compose via the middleware prop:

<Field
  name="country"
  inputType="select"
  label="Country"
  middleware={[addRESTOptions("/api/countries")]}
/>

The middleware signature is (Component) => Component. Multiple middleware compose in array order, with the first middleware as the outermost wrapper.

Built-in Middleware

addRESTOptions - Fetches options from an API endpoint:

import { addRESTOptions } from "@canonical/react-ds-global-form";

<Field
  name="category"
  inputType="select"
  middleware={[
    addRESTOptions("/api/categories", {
      transformData: (data) => data.categories,
    }),
  ]}
/>

addRESTValidation - Validates field values against an API:

import { addRESTValidation } from "@canonical/react-ds-global-form";

<Field
  name="username"
  inputType="text"
  middleware={[
    addRESTValidation("/api/validate-username", {
      debounceWait: 300,
      minLength: 3,
    }),
  ]}
/>

Custom middleware follows the same factory pattern: an outer function accepts configuration and returns a HOC.

Conditional Display

Fields can conditionally render based on other field values:

<Field
  name="company"
  inputType="text"
  label="Company"
  condition={[
    ["accountType"],
    ([type]) => type === "business",
  ]}
/>

The field only renders when the condition function returns true.

Storybook

cd packages/react/ds-global-form
bun run storybook

The Storybook configuration includes MSW integration for mocking backend responses.

Component Specifications

Form component specifications are defined in the Design System Ontology.

版本与发布记录

@canonical/react-ds-global-form 共发布 38 个版本,首次发布于 2026/01/18;过去 12 个月发布了 38 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.45.0latest2026/10/2317 天前
0.44.12026/10/081 个月前
0.44.02026/10/051 个月前
0.43.02026/10/011 个月前
0.42.02026/09/281 个月前
0.40.02026/09/211 个月前

关于 @canonical/react-ds-global-form 的常见问题

@canonical/react-ds-global-form 是什么?

Form components for the Pragma design system. This package provides a field system built on react-hook-form with two core patterns: a field switch for rendering different input types and middleware composition for extending field behavior.

如何安装 @canonical/react-ds-global-form?

在项目目录运行 npm install @canonical/react-ds-global-form,或使用 pnpm add / yarn add / bun add。

@canonical/react-ds-global-form 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@canonical/react-ds-global-form 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@canonical/react-ds-global-form 可以商用吗?

@canonical/react-ds-global-form 使用 LGPL-3.0 许可。弱 Copyleft:作为库被调用时通常不要求开源你自己的代码,但修改库本身需以 LGPL 开源;前端打包合并后的适用边界存在不同理解。以上为概括,不构成法律意见,请以许可原文为准。

@canonical/react-ds-global-form 的下载量有多少?

根据 npm 官方下载统计,@canonical/react-ds-global-form 在 2026/10/09 – 2026/11/07 这 30 天内被下载 7,734 次,日均约 258 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@canonical/react-ds-global-form 还在维护吗?

最新版本 0.45.0 发布于 2026/10/23(17 天前);至今共发布 38 个版本,其中过去 12 个月发布了 38 个;首次发布于 2026/01/18。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。