Skip to content

Latest commit

 

History

History
302 lines (261 loc) · 16.5 KB

File metadata and controls

302 lines (261 loc) · 16.5 KB

Chucao components (StencilJS)

The Chucao design system (colors, typography, spacing, layout — see DESIGN.md and chucao-tokens.json) is packaged as framework-agnostic Web Components built with StencilJS and published to npm as @devschile/chucao.

Stencil components are standard Web Components, so they work in React, Vue, Angular, Svelte, or without any framework. See using-the-library.md for how to consume them.

Getting started

pnpm install
pnpm start

pnpm start runs stencil build --dev --watch --serve.

To build the components for production:

pnpm run build

pnpm run build runs the Stencil build plus the React and Vue wrapper builds (build:react and build:vue).

To run the component tests (including coverage):

pnpm test
pnpm run test:coverage

See testing.md for details on the testing setup and how the coverage report works.

Need help with Stencil itself? Check out the docs.

Project structure

src/
├── components/   Stencil components (one folder per component)
├── global/       Global stylesheet (utility classes & CSS variables), bundled into every build
├── tokens/       Design tokens generated by toki — do not edit by hand
└── index.ts      Public entry point (re-exports tokens)

Design tokens

The files under src/tokens (tokens.ts, tokens.css, tokens.d.ts, types.ts) are generated from chucao-tokens.json by tokido not edit them by hand. src/global/chucao.css (the file bundled into every build via globalStyle) imports tokens.css directly, so there's a single source of truth for the generated :root custom properties. See src/tokens/README.md for their naming conventions, CSS custom properties and TypeScript usage.

The token schema covers color (background, surface, border, accent, status, text, brand), typography (families, size, weight, tracking), a spacing scale (2xs6xl plus shift/elevate), radius (sm/md/pill), border (width, accent bar), shadow, duration, easing, focus, opacity, z-index, effect (blur) and layout. Component stylesheets reference these via var(--token-name) — no hardcoded colors or sizes. The DESIGN.md-style short aliases (--bg, --accent, --radius, …) live in chucao.css and map onto the generated tokens.

To change tokens, edit chucao-tokens.json and run pnpm run tokens. The script regenerates the stencil output with toki build (reading toki.config.ts), copies the generated files into src/tokens/, and formats them with Prettier. Run pnpm run tokens:check (→ toki validate) to lint the token file.

Docs-site gallery

The component gallery on the docs site is generated, not hand-maintained. pnpm run generate:gallery reads the demo specs in scripts/gallery-data.mjs (which must cover every component folder under src/components/) and rewrites two files:

  • docs-site/index.html — the .comp demo blocks inside <section id="componentes">, between the <!-- GALLERY:START --> / <!-- GALLERY:END --> markers.
  • docs-site/assets/js/app.js — one customElements.whenDefined(...) block per component, containing the event-log bind(...) wiring and the seeding derived from each entry's bindings/init. The gate is deliberately per-component: whenDefined never resolves for an element the loaded library does not define, so a single shared gate would let one unpublished component silently stop every other demo from being seeded.

The GitHub Pages workflow runs pnpm run generate:gallery before assembling the publish directory, and check-pr runs it too and then fails if the result differs from what is committed — running the generators without checking their output let stale generated files sit in main unnoticed. Regenerate and commit before pushing:

pnpm build && pnpm run generate:gallery

When adding a component, add a matching entry to scripts/gallery-data.mjs; the generator fails if a component folder lacks a gallery entry (or vice versa).

Adding or changing a component means releasing it. The gallery ships to the live site on the next main push (.github/workflows/docs.yml), but the site loads the library from the CDN chucao/latest/ prefix, which only updates on a release (pnpm run release). The docs publish is gated on that: pnpm run check:released (run by docs.yml) diffs the local build's component manifest — tags and props — against the released CDN bundle, and fails the deploy if the gallery references a component, prop or attribute the release does not carry yet. This applies to a new component and to an existing one whose API or behavior changes. Merge a component change and cut its release together.

Sidebar, search and anchors

The sidebar's component list is a second generated region, delimited by <!-- SIDEBAR:START --> / <!-- SIDEBAR:END -->, emitted from the same gallery array in the same pass as the .comp blocks. A component therefore cannot appear in the gallery without appearing in the navigation.

Each .comp block carries id="<tag>" so components are linkable (#ch-select) and tabindex="-1" so following such a link moves focus to the block rather than only scrolling. The generator fails the build if a tag is not usable as a URL fragment, alongside the existing coverage checks.

The sidebar is emitted as real markup rather than assembled at runtime, so navigation still works with JavaScript disabled. The four section links (Colores, Tipografía, Marca, Documentación) are hand-written — they do not grow with the component count, so generating them would be pointless.

docs-site/assets/js/nav.js holds the interaction layer: search filtering, active-item tracking via aria-current, and the mobile drawer. It is hand-written and the generator never touches itapp.js is rewritten wholesale on every run, so interaction code there would be deleted by the next pnpm run generate:gallery.

The search field and the drawer toggle are created by nav.js, not present in the HTML. Without JavaScript there is no dead control to confuse anyone; the complete fallback is the full static list.

The search query round-trips through the URL as ?q=, so a filtered view is shareable and Back steps through the queries actually searched for. It is written on commit — on blur or after a pause — never on each keystroke: mixing replaceState while typing with pushState on commit corrupts history, because the replace overwrites the entry the previous push created.

Previewing the docs-site locally

docs-site/index.html loads the library from the CDN (static.devschile.cl/chucao/latest/). That is correct for the published site, but it means the site always shows the last release, never the working tree — a component you just added stays invisible there until a release ships.

pnpm run docs:serve

That builds, regenerates the gallery, and serves the site against the local Stencil output on http://localhost:4173 (set PORT to change it). It assembles a throwaway .docs-preview/ directory rather than editing docs-site/, so the committed HTML keeps pointing at the CDN and there is no dev-only variant to commit by accident. The CDN URLs for the stylesheet, the module and the self-hosted fonts are rewritten to the local copy, so the preview also works offline, and a badge in the corner marks it as a local build.

It is a snapshot, not a watcher: re-run it after changing a component.

Naming & API conventions

When creating new component tags, avoid using chucao or stencil alone as the tag name; instead prefix component tags with ch- (e.g. ch-input) so they don't collide with other libraries on the page.

Beyond tag naming, follow these conventions — already used consistently across ch-accordion, ch-alert, ch-badge, ch-button, ch-card, ch-checkbox, ch-divider, ch-input, ch-link, ch-modal, ch-radio, ch-select, ch-spinner, ch-switch, ch-tabs, ch-textarea, and ch-tooltip — so future components stay consistent as the library grows. See v1-readiness.md for the full readiness assessment that identified and documented these conventions.

  • Custom events: prefix with ch and use camelCase (chClick, chChange, chInput), mirroring the native event they wrap (click, change, input).
  • Variant props: type them as a string union (e.g. 'primary' | 'secondary') and export the union as a named type alongside the component class (e.g. ChButtonVariant, ChBadgeVariant), so consumers can import and reuse the type.
  • Standard interactive-component props: any component that wraps a native form control should expose disabled (@Prop() disabled = false;), required (@Prop() required = false;), name (@Prop() name?: string;), and, when it holds a value, value as @Prop({ mutable: true }) value = '' — reflect each prop straight onto the native element (e.g. <button disabled={this.disabled}>, <input disabled={this.disabled} required={this.required} />). This is the pattern ch-input and ch-select already follow, and that ch-button was updated to match. The form controls declare name, disabled and required as @Prop({ reflect: true }) so the host also carries them: the platform reads those attributes on the host for form association (see the form participation bullet below).
  • Form participation & native validation: the six form controls (ch-checkbox, ch-input, ch-radio, ch-select, ch-switch, ch-textarea) are form-associated custom elements (@Component({ formAssociated: true })). Inside a <form> they behave like native controls: their value/checked state is submitted through ElementInternals.setFormValue(), they appear in form.elements, and form.checkValidity()/FormData see them. required with an empty value is invalid (valueMissing), blocking submit, and the invalid/errorMessage props are mirrored into setValidity({ customError }), so the host matches :invalid/:valid. The constraint-validation API for form-associated custom elements lives on element.internals (not on the element): checkValidity(), validationMessage, validity, willValidate. formResetCallback restores value/checked to the initial state on form reset. This is a 2.0 breaking change: controls inside a form that previously contributed nothing now participate — consumers who compensated by hand (e.g. hidden inputs feeding FormData) must remove that workaround to avoid duplicate values.
  • Accessibility: prefer native, keyboard-accessible elements (<button>, <input>, <select>, <textarea>) inside the shadow root instead of reimplementing their semantics — this is what gives ch-button, ch-input, ch-select, and ch-textarea working Tab/Space/Enter navigation for free. Because aria-* attributes set on the custom element host aren't automatically forwarded into the shadow DOM, components that need a label expose it explicitly: ch-input/ch-select/ch-textarea accept a label prop rendered as a <label> associated via a generated id/for pair, ch-checkbox/ch-switch wrap their native control in a <label> (implicit labeling), ch-radio renders a role="radiogroup" labelled via aria-labelledby, and ch-button accepts a label prop applied as aria-label on the native <button> (for icon-only buttons). Interactive elements must also keep a visible :focus-visible style (see ch-button.css) so keyboard users can see where focus is.
  • Composite widgets (keyboard + panels): ch-tabs and ch-accordion implement keyboard-accessible, non-form composites. ch-tabs follows the WAI-ARIA tablist pattern: arrow keys move and activate (Left/Right wrap around, Home/End jump to first/last, disabled tabs are skipped), the active tab carries aria-selected and a roving tabindex (0/-1), and each tab is wired to its role="tabpanel" via aria-controls/aria-labelledby. ch-accordion follows the WAI-ARIA accordion pattern with native <button> toggles (Enter/Space for free), single-open semantics, and aria-expanded/aria-controls/role="region" wiring. Both keep panel content in the consumer's light DOM, distributed into the shadow root via a named slot per panel: panel-<value> (ch-tabs), panel-<value> (ch-accordion). Only the active panel is rendered visible.
  • Overlays: ch-modal is built on the native <dialog> opened with showModal(), so the browser owns the hard parts — moving focus in and returning it on close, marking the rest of the page inert, Escape, the top layer, ::backdrop, and the implicit dialog role with aria-modal="true". Deliberately absent is a focus trap: the W3C APA group concluded a modal should still let keyboard users reach browser UI, so hand-rolling one would be less correct. Two things the platform does not cover are added here — locking the page behind the dialog so it cannot scroll (reference-counted, so nested modals and unmounts cannot unlock it early), and closing on a click outside the dialog, which is closedby="any" in the platform but is implemented in JavaScript for every browser alike while Safari lacks it. Because a slotted heading cannot be referenced with aria-labelledby across the shadow boundary, the accessible name comes through the label prop: always pass it, with the same text as the heading. Native <dialog> events are wired with addEventListener rather than JSX, since Stencil derives a listener's event name from whether on<name> exists on window and that differs between the browser and the test environment for pointer events.
  • Cross-boundary ARIA: ch-tooltip takes both its trigger and its text from the consumer's light DOM through the trigger and content slots. That is not a stylistic choice — an aria-describedby IDREF only resolves within a single tree, and slotted nodes stay in the document tree even though they render inside the shadow root, so putting both there is what makes the association work at all. Rendering the text inside the shadow root would need a reference pointing into a shadow tree: ARIA element reflection (ariaDescribedByElements) only points outwards, and assigning it inwards fails silently — the assignment is accepted and reads back empty. The platform's real fix, referenceTarget, has not shipped. The component generates an id on the text (respecting one the consumer already set), marks it role="tooltip", and points the trigger at it, re-running on slotchange since a framework re-render can replace the slotted nodes. The trigger must be a natively focusable element: aria-* set on a custom element host does not reach the native element inside it, so a ch-button trigger would not be described — tracked in the library-wide discussion on accessible names. Hover and focus are tracked as two independent flags rather than one open boolean, so a pointer passing over and away cannot hide a tooltip whose trigger still holds keyboard focus, and a third flag records an Escape dismissal. Escape is handled on the document, not the host: a tooltip opened by hovering leaves focus wherever it was, so a host-scoped handler would never see the key. The empty .bubble::before in the stylesheet is load-bearing — it spans the offset between trigger and bubble so the pointer stays inside the component while it crosses, which is what keeps the bubble reachable. Without it the pointer passes over the page behind the gap, the host gets mouseleave, and the bubble disappears before it can be read. Together these cover the three parts of WCAG 1.4.13 (hoverable, dismissible, persistent).
  • Links & dividers: ch-link renders a native <a> with default/muted variants; it auto-adds rel="noopener noreferrer" for target="_blank" and, when disabled, renders a non-interactive element with aria-disabled="true". The underline is an opt-out: set --ch-link-text-decoration to none on the host (e.g. for button-like CTAs), or style the internal element via ch-link::part(link). ch-divider renders a native <hr> with horizontal (default) / vertical orientations and aria-orientation.