Skip to content

Latest commit

 

History

History
345 lines (280 loc) · 13.7 KB

BuildingComponents.md

File metadata and controls

345 lines (280 loc) · 13.7 KB

Building Components

The purpose of Zero is to help you build UI components for the web. So, this is a guide to help towards that end.

The two most important concepts surrounding Zero components are the view function, and props.

The view function is the thing that renders your component. It should (generally) be a pure function that accepts a map of prop values, and returns some SubZero markup (dubbed the vDOM, or virtual DOM).

What happens with this vDOM depends on how Zero is being used.

If it's being used in the browser to build web components, then each Zero component becomes a Web Component. And SubZero (which Zero is built upon) will update the component's shadow DOM to reflect the views vDOM. Whenever the component's prop values change, this process will happen again, so the component's DOM will stay up to date. We call this reactivity.

If the component is being rendered to HTML (for SSR, SSG, etc.) then SubZero will call the registered view function with any props given for the element, and the resulting vDOM will be rendered into a declarative shadow DOM for the component. HTML rendering can be used either as the sole rendering for the component, or as a pre-rendering step, in which case a client-side component implementation can take over the HTML-rendered DOM.

Props are the component inputs. They must be explicitly declared when registering a component. The props can be given either as a set of names (which will be setup in the default way) or as a map of names to some value... which allows for more customization, but gets more complicated for web components. For HTML-rendering, the map values are ignored (for now) so map vs set makes no difference.

(ns example
  (:require
    [zero.config :as zc]))

(defn- my-component-view
  [{:keys [whom]}]
  [:div "Hello, " whom "!"])

(zc/reg-components
 :my-component
 {:props #{:whom}
  :view my-component-view})

Components can be implemented in *.cljc files to make them usable from both Clojure (for SSR or SSG) and ClojureScript. However it may also be useful to have separate implementations of the same component, for HTML-rendering and client-side rendering. In which case I'd recommend separate *.cljs and *.cljc files with the same base name. For applications where the back-end and front-end are both built in ClojureScript... well, you'll probably need to do some dynamic checking, or use a Closure define to get the compiler to get rid of the unwanted implementation during tree shaking.

Web Components

When running Zero within the browser, and with the web component plugin installed (via zero.wcconfig/install!), every Zero component becomes a Web Component in your browser. This means the browser will recognize when a matching DOM element is attached to the document (no matter how this happens) and will wire it up with your component logic. It's rather convenient.

Web components have a lot of extra registration options that don't make much sense for HTML-rendered components. I'll give a brief overview here, but check the SubZero docs for details.

Props

The most important thing is that web components have more powerful props. Whereas HTML-rendered components need to be passed all prop values explicitly; web components can get prop values from various sources: JavaScript properties on the host element, HTML attributes on the host element, or any watchable thing. This allows web components to be much more dynamic.

When we register a web component with a set of prop names (rather than a full map) all props are given the :default behavior. Which means a JS property matching the prop name will be generated for the component class, and the component will watch for changes to any attribute matching the prop name. The current prop value will reflect the last thing updated, out of the attribute and JS property. For most components, this is okay behavior for all props.

(zc/reg-components
  :my-component
  {:props #{:foo :bar}
   :view my-view})

When we need to tie the component's view to some external state, a watchable thing can be given for the value in the property map... or a function that returns a watchable thing... or a map with :state-factory and :state-cleanup functions...

(defonce! !my-external-state (atom nil))

(zc/reg-components
  :my-component
  {:props
   {:my-external-state !my-external-state
    :my-state-factory (fn [^js/HTMLElement _the-component-dom]
                        (atom nil))
    :my-state-factory-with-cleanup {:state-factory
                                    (fn [^js/HTMLElement the-component-dom]
                                      (create-watchable-thing the-component-dom))
                                    :state-cleanup
                                    (fn [the-watchable-thing ^js/HTMLElement the-component-dom]
                                      (cleanup-the-thing the-watchable-thing))}}
  :view my-view})

Zero provides a convenience function (zero.dom/internal-state-prop) to help setup a property for internal component state, since this is a fairly common need.

:props {:state (zero.dom/internal-state-prop {:foo "foo"})}
;==> or <==;
:props {:state (zero.dom/internal-state-prop (fn [^js/HTMLElement the-component-dom] {:foo "foo"}))}

Focus

Properly managing the focus of input components is essential to good UI. The default is that web components just aren't focusable, which is generally what's wanted for non-interactive or container components; but isn't ideal for controls. Use the :focus option to adjust this.

Possible values are :self and :delegate. The :delegate option causes the component's shadow DOM to be created with delegatesFocus... which comes with a few oddities. 1) This can't be undone, so changing this in a hot reload can cause some weird behavior 2) If the component has been HTML-pre-rendered then it'll already have a shadow DOM, and this option won't have any effect.

Basically the effect of :delegate is that any time your component is clicked, its first focusable child will receive the focus instead of the component itself.

The :self option just makes the component itself focusable, by setting tabIndex = 0 if it's null.

(zc/reg-components
  {:props #{:foo :bar}
   :focus :self
   :view my-view})

Inheriting Document CSS

It may be useful (though these days I avoid it) to allow your components to borrow the styling from your top level document, since the shadow DOM mitigates this. Set :inherit-doc-css? to enable this behavior. Note that it fetches the CSS and wraps it in a CSSStyleSheet, which ignores imports.

(zc/reg-components
  {:props #{:foo :bar}
   :inherit-doc-css? true
   :view my-view})

Form Controls

When building HTML form controls, set the :form-associated? option. This tells SubZero to setup the component class as a form input, allowing the form value, status, error message, etc. to be controlled via the #internals prop on your component's :root>.

(zc/reg-components
  {:props #{:foo :bar}
   :form-associated? true
   :view my-view})

Rendering To HTML

Use subzero.plugins.html/html to render SubZero markup to an HTML string, or subzero.plugins.html/write-html to render to a writer.

In either case, the function takes a SubZero database as the first arg. Generally you should pass zero.config/!default-db here, as this is where your Zero components will be registered by default.

For write-html, the second arg is the writer.

An option map can be given as the next argument, which can have a :doctype to be added at the start of the rendered HTML.

All other args are interpreted as markup, and rendered as HTML.

Attributes for registered Zero components will be rendered as CDF, so the structure of the data can be restored client-side. This will happen automatically if the component is registered both when rendering the HTML, and as a web component on the client.

The HTML renderer will also try to render event handlers (in :#on maps) as :zero.dom/listen components, which will try and register the event listener on the client-side. But this will only work correctly if the event handlers (map values) are things that can be serialized and deserialized as something that'll work as an event handler... for example... actions. Zero's client-side DOM utilities (from zero.dom) also need to be installed for this to work.

Likewise, the HTML renderer will try to render bindings (from :#bind maps) as :zero.dom/bind components, which will try and setup the bindings client-side.

The :root>

A component's view function may return a special [:root> ...] as the top-level node of its vDOM. This serves as a place to attach component-level customizations.

For example setting a #style prop on this node sets up the default styling for the component's host element. Setting :#on event handlers attaches the event handlers to the component's shadow root. See the SubZero docs for details.

Component lifecycle events are dispatched on the shadow root, so we can handle them with event handlers on the :root> vNode.

(defn my-view
  []
  [:root>
   :#on {:connect (fn [^js/Event ev] ,,,)
         :render  (fn [^js/Event ev] ,,,)
         :update  (fn [^js/Event ev] ,,,)
         :disconnect (fn [^js/Event ev] ,,,)}
    ,,,])
  • :connect - when the component is attached to the document, after the first render
  • :render - after every render
  • :update - after all but the first render after connecting
  • :disconnect - when the component is removed from the document

Use :#on-host instead of :#on to handle UI events on the component's host element, for example focus/blur, mouseover/mouseout, etc. Most user events won't be dispatched on the shadow root, unless they're bubbling up from a child. Be careful though if using actions to handle these events, the context received will be from the host's element, which might be unexpected. For example ::z/host will refer to the parent component's host element, ::z/root to the parent component's shadow root, etc.

Use :#css to add stylesheets to the component. This can be given as a string, a CSSStyleSheet instance, or a vector of zero or more of the same. Strings are treated as stylesheet URLs if they begin with http or /, otherwise as raw CSS text.

Slots

Slots are a powerful feature of web components, and are especially helpful for vDOM based rendering, as they allow for some nice performance improvements.

Essentially, slots allow child elements to be 'projected' into our components from a parent. These child elements are independent from our component, so they can be updated efficiently by the common parent, without forcing our component to also update... I'm not happy with this explanation. Just read the docs instead of my rambling.

Here's an example:

(defn my-card-view
  []
  [:section
   [:h1 [:slot :name "heading"]]
   [:p [:slot :name "body"]]])

(zc/reg-component
 :my-card
 {:view my-card-view})

;==> and we can use it like <==;
[:my-card
 [:span :slot "heading" "My Heading"]
 [:span :slot "body" "The main content"]]

A fairly common need when it comes to slots, is to be able to adjust our UI depending on whether the user has plugged anything into our slots. For this Zero has zero.dom/slotted-prop.

(defn my-card-view
  [{:keys [heading-els body-els]}]
  [:section
   [:h1 [:slot :name "heading"]
    (when (empty? heading-els) "<no heading slotted>")]
   [:p [:slot :name "body"]
    (when (empty? body-els)
      "<no body slotted>")]])

(zc/reg-component
 :my-card
 {:props {:heading-els (zd/slotted-prop :slots #{:heading})
          :body-els (zd/slotted-prop :slots #{:body})}
  :view my-card-view})

Signals

Signals are a Zero feature that allow some behavior of a component to be triggered from the outside. They're rarely needed, and should be avoided when possible... but when the need does come up, you'll be happy they're available.

Signals, like Zero's state management types, have value semantics. So they can be serialized/deserialized, compared, etc. And they don't break the data-oriented nature of component view functions.

Here's how they work.

(ns example
  (:require
    [zero.core :refer [sig act] :as z]))

;; remember, signals are data, so two instances with the same key
;; are considered to be the same signal
(def my-sig (sig ::my-unique-key))

;; add a listener, any number of these can be added
(z/sig-listen my-sig ::my-listen-key
  (fn []
    (println "my-sig has been invoked")))

;; invoke the trigger
(my-sig)

;; remove the listener
(z/sig-unlisten my-sig ::my-listen-key)

More conveniently, instead of manually listening/unlistening, just put the signal in the key position of an element's listener map in the vDOM. This also provides access to an inferred context (much like when handling events), but without the event-specific keys.

(defn- my-view
  [{:keys [focus-sig]}]
  [:input
   :#on {focus-sig (act [::zd/invoke (<<ctx ::z/current) "focus"])}])

This is typically how signals will be used in practice. The signal that's passed into the component as a prop value can be invoked externally, which will cause the internal input to be focused.

TODO

That's all I've got so far. More to come.