Skip to content

mainej/headlessui-reagent

Repository files navigation

headlessui-reagent

Reagent wrappers for @headlessui/react, bringing accessible, keyboard-friendly, style-agnostic UI components to Reagent and re-frame projects.

Installation

Install as a Clojure dependency. Assuming you run your project with shadow-cljs, @headlessui/react will be installed as a JS dependency. Otherwise, you may have to install it yourself with npm/yarn.

Since v1.4.0.32, headlessui-reagent tracks @headlessui/react's versioning. That is, the first three segments of the version (1.4.0) indicate that this library was built with @headlessui/react version 1.4.0. The last segment (32) distinguishes between releases of this library that were all built with the same version of @headlessui/react.

If for some reason you need an earlier version, both v1.2.0 and v1.2.1 of headlessui-reagent were built with @headlessui/react 1.2.0. Earlier releases were built with @headlessui/react 1.0.0.

Usage

Usage follows the Headless UI API. For example, to use a Disclosure in Reagent, with Tailwind CSS (though any styling system will work):

(require '[headlessui-reagent.core :as ui])

[ui/disclosure
  [ui/disclosure-button {:class [:w-full :px-4 :py-2 :text-sm :font-medium :text-purple-900 :bg-purple-100 :rounded-lg]}
    "Explain"]
  [ui/disclosure-panel {:class [:px-4 :pt-4 :pb-2 :text-sm :text-gray-500]}
    [:p "Some explanation."]]]

To see each of the components in action, check out the examples.

Styling the active item

To conditionally apply markup or styles based on the component's state, you have a few choices.

First, if you're using Headless UI with Tailwind CSS, as many people do, consider using the @headlessui/tailwindcss plugin. That'll let you target specific UI states with plain CSS classes.

[ui/menu
  [ui/menu-button "More"]
  [ui/menu-items
   [ui/menu-item
    [:a.ui-active:bg-blue-500.ui-active:text-white.ui-not-active:bg-white.ui-not-active:text-black
     {:href "/account-settings"}
     "Account Settings"]]
   ,,,]]

For a more programmatic approach, Headless UI provides "render props". If the Reagent component is given a single function as a child, the function is called with a hash map of render props (e.g. :open for a Disclosure). The return value of the function, which should be a single (hiccup-style) component, will be rendered.

[ui/disclosure
 (fn [{:keys [open]}]
   [:<>
    [ui/disclosure-button (if open "Hide" "Show")]
    [ui/disclosure-panel ,,,]])]

If you need to control the CSS classes only, not the component's contents, :class can be a function which will receive the render props:

[ui/disclosure-button {:class (fn [{:keys [open]}]
                                [:border (when open :bg-blue-200)])}
 "Show more"]

Rendering a different element for a component

Many Headless UI components accept an "as" prop, which controls how they are rendered into the dom. If the corresponding Reagent component is given an :as prop, it can be any hiccup-style component: a string, a keyword or a function which returns hiccup.

If :as is a full-fledged Reagent component (i.e. a function which returns hiccup), then that component must accept two arguments, its properties and its children:

(defn panel-ul [props children]
  (into [:ul.bg-red-500 props] children))

[ui/disclosure-panel {:as panel-ul}
  [:li "Note this."]
  [:li "This too."]]

The props will contain ARIA attributes, event handlers and other attributes necessary for the :as component to work correctly, so you must use them.

The above example is so simple it would be more easily written as:

[ui/disclosure-panel {:as :ul.bg-red-500}
  [:li "Note this."]
  [:li "This too."]]

Or, closest to the Headless UI style, as:

[ui/disclosure-panel {:as "ul", :class [:bg-red-500]}
  [:li "Note this."]
  [:li "This too."]]

Picking an item

The Listbox, RadioGroup and Combobox components are designed to assist in picking an item from a list of items. Headless UI coordinates this by having a root element and several child elements. The child elements each have a value, to identify themselves. The root element also has a value for the currently selected item and an onChange handler that is called with a different value when another item is selected.

In this library, these correspond to the :value and :on-change attributes. The :value must be a JavaScript object, and similarly the argument to the :on-change callback will be a JavaScript object. The library does not automatically convert between JavaScript and ClojureScript.

This begs the question, what's the best way to use these components when the items are ClojureScript objects? The trick is to ensure that the items each have a unique identifier that is a JavaScript primitive (numbers and strings are preferred because they are primitives in both ClojureScript and JavaScript). We use this unique identifier as the item's :value. When a new item is selected, :on-change will be called with the primitive identifier. At that point, we're back in ClojureScript code, so we can use the identifier to lookup the full item.

Here's an example. Notice that :id, an integer, is used as the :value in both the ui/listbox and ui/listbox-option. That is, to Headless UI, it's assisting in picking an integer. That integer is converted back into a full person in :on-change.

(def people [{:id 1, :name "Wade Cooper"}
             {:id 2, :name "Arlene Mccoy"}
             {:id 3, :name "Devon Webb"}
             {:id 4, :name "Tom Cook"}
             {:id 5, :name "Tanya Fox"}
             {:id 6, :name "Hellen Schmidt"}])

(def person-by-id (zipmap (map :id people) people))

(reagent.core/with-let [!selected (reagent.core/atom (first people))]
  (let [selected @!selected]
    [ui/listbox
     {:value     (:id selected)
      :on-change #(reset! !selected (get person-by-id %1))}
     [ui/listbox-button (:name selected)]
     [ui/listbox-options
      (for [person people]
        ^{:key (:id person)}
        [ui/listbox-option
         {:value (:id person)}
         (:name person)])]]))

Known bugs

There are some known limitations to the interop between Reagent and Headless UI. Bug fixes welcome!

Rendering children directly

In some cases where Headless UI would usually render a wrapper element, it permits rendering the children directly instead, by passing React.Fragment as "as". This is not supported because Headless UI and Reagent fail to convey props between them.

;; DON'T do this
[ui/menu-button {:as :<>}
  [:button.block {:type "button"} "Open"]]

Acknowledgements

If you'd like to support Tailwind Labs, the creators of Headless UI, consider signing up for Tailwind UI. You'll get access to hundreds of UI components, many of which use Headless UI, and which can be adapted to work with headlessui-reagent.

headlessui-reagent isn't an official part of Headless UI, and I don't get anything when you sign up for Tailwind UI. I just believe that Tailwind CSS and Headless UI are a natural fit for Reagent/Hiccup projects, and want to see them thrive.

License

Copyright © 2022 Jacob Maine

Distributed under the MIT License.