How do you create a theme with Halogen?

Hi, currently I’m building an app with vanilla Halogen, getting a bit tired of only reusing CSS classes and think is time to make a Theme for my project.

Halogen examples has various methods of componentize things and was wondering about opinions/tradeoff on those?

This is Halogen’s the basic example:


module Example.Basic.Button (component) where

import Prelude

import Halogen as H
import Halogen.HTML as HH
import Halogen.HTML.Events as HE
import Halogen.HTML.Properties as HP

type State = { enabled :: Boolean }

data Action = Toggle

component :: forall q i o m. H.Component q i o m
component =
  H.mkComponent
    { initialState
    , render
    , eval: H.mkEval $ H.defaultEval { handleAction = handleAction }
    }

initialState :: forall i. i -> State
initialState _ = { enabled: false }

render :: forall m. State -> H.ComponentHTML Action () m
render state =
  let
    label = if state.enabled then "On" else "Off"
  in
    HH.button
      [ HP.title label
      , HE.onClick \_ -> Toggle
      ]
      [ HH.text label ]

handleAction ∷ forall o m. Action → H.HalogenM State Action () o m Unit
handleAction = case _ of
  Toggle ->
    H.modify_ \st -> st { enabled = not st.enabled }

This seems like a lot of boilerplate (for themed components), probably has a runtime cost, and is not extensible. If I want to extend the component, I cannot pass more default properties to the button.

I could do something like:

type Input =
  { enabled :: Boolean
  , buttonProps :: I.HTMLbutton
  , buttonChildren :: Array (HTML w i)
  }

But I’m not sure if this is a common practice for Theme components; it is not completely clear to me if “Theme Components build to use with Halogen” should be of type H.Component or a mix with HTML w i, because primitives are not encoded as H.Components, they are “elements”, expecting indexed properties, but H.Components are often a Record in the docs.

There are variants expressed with an underscore buttonbutton_ with empty properties, does it mean that small varials could be just HTML? like buttonPrimary is just HH.button with one class applied, like this:

module Theme.Button.Button where

import Prelude

import DOM.HTML.Indexed as I
import Halogen.HTML as HH
import Halogen.HTML.Properties as HP

data Variant = Primary

classFromButton ∷ Variant → HH.ClassName
classFromButton = case _ of
  Primary -> HH.ClassName "call_to_action_button"

button ∷ ∀ w i. Variant → HH.Node I.HTMLbutton w i
button variant a b =
  HH.button
    ( [ HP.classes [ classFromButton variant ]
      ] <> a
    )
    b

or

module Theme.Button.Button where

import Prelude

import DOM.HTML.Indexed as I
import Halogen.HTML as HH
import Halogen.HTML.Properties as HP

buttonPrimary ∷ ∀ w i. HH.Node I.HTMLbutton w i
buttonPrimary props children =
  HH.button
    ( [ HP.classes [ HH.ClassName "call_to_action_button" ]
      ] <> props
    )
    children

(I am still a bit puzzled on how to merge props, like HP.classes because it seems Halogen only renders the first of every prop)

Then there are the higher order components:

component
  :: forall q i o m
   . H.Component q i o m
  -> H.Component (Query q) i (Message o) m
component innerComponent =
  H.mkComponent
    { initialState
    , render: render innerComponent
    , eval: H.mkEval $ H.defaultEval
        { handleAction = handleAction
        , handleQuery = handleQuery
        }
    }
render
  :: forall q i o m
   . H.Component q i o m
  -> State i
  -> H.ComponentHTML (Action o) (ChildSlots q o) m
render innerComponent state
  | state.open =
      HH.div
        [ HP.classes [ H.ClassName "Panel", H.ClassName "Panel--open" ] ]
        [ HH.div
            [ HP.classes [ H.ClassName "Panel-header" ] ]
            [ HH.button
                [ HP.classes [ H.ClassName "Panel-toggleButton" ]
                , HE.onClick \_ -> Toggle
                ]
                [ HH.text "Close" ]
            ]
        , HH.div
            [ HP.classes [ H.ClassName "Panel-content" ] ]
            [ HH.slot _inner unit innerComponent state.input HandleInner ]
        ]
  | otherwise =
      HH.div
        [ HP.classes [ H.ClassName "Panel", H.ClassName "Panel--closed" ] ]
        [ HH.div
            [ HP.classes [ H.ClassName "Panel-header" ] ]
            [ HH.button
                [ HP.classes [ H.ClassName "Panel-toggleButton" ]
                , HE.onClick \_ -> Toggle
                ]
                [ HH.text "Open" ]
            ]
        ]

It is nice to add arbitrary H.Component children. It behaves similarly to Reactjs children; passing down default properties is a bit complex, but I think I can, and also, I’m not sure if I can access the children in any way. React has things like the Children API, which can be used to transform the children:

import { Children } from 'react';

function RowList({ children }) {
  return (
    <div className="RowList">
      {Children.map(children, child =>
        <div className="Row">
          {child}
        </div>
      )}
    </div>
  );
}

Also things like cloneElement

import { cloneElement } from 'react';

// ...
const clonedElement = cloneElement(
  <Row title="Cabbage">
    Hello
  </Row>,
  { isHighlighted: true },
  'Goodbye'
);

console.log(clonedElement); // <Row title="Cabbage" isHighlighted>Goodbye</Row>

to create a new React element using another element as a starting point, heavily used in themes.

A modern approach would be the Context API:

export default function List({ items, renderItem }) {
  const [selectedIndex, setSelectedIndex] = useState(0);
  return (
    <div className="List">
      {items.map((item, index) => {
        const isHighlighted = index === selectedIndex;
        return (
          <HighlightContext.Provider key={item.id} value={isHighlighted}>
            {renderItem(item)}
          </HighlightContext.Provider>
        );
      })}

For passing down properties, a must in theme components that have an internal state of communication.

Back to how to make components, which is preferred? Higher order components? Is there a benefit in making a simple “Primary Button” a Halogen component if it has no lifecycle?

Also, with Reactjs, passing custom and default props is “easier”, as I can read the custom one’s restructuring and then assign.

import * as React from 'react';
import Stack from '@mui/material/Stack';
import Button from '@mui/material/Button';

export default function BasicButtons() {
  return (
    <Stack spacing={2} direction="row">
      <Button variant="text">Text</Button>
      <Button variant="contained">Contained</Button>
      <Button variant="outlined">Outlined</Button>
    </Stack>
  );
}

If I want to pass on more default properties, it is easy to add them and match on the custom ones.

I know at the end of the day, it is a decision what API I provide to the theme, but I wonder how other people are Themefying their apps with Halogen.

Does anyone has some experience in this area?