the-dr-lazy / purescript-monarch

A simple but powerful PureScript library for declaring pure UIs
Mozilla Public License 2.0
9 stars 0 forks source link

Monarch element API proposal #10

Open the-dr-lazy opened 4 years ago

the-dr-lazy commented 4 years ago

RFC: Monarch element API

Motivation

Currently in TEA (The Elm Architecture) business logic (Send API request after button has been clicked, and etc...) and UI logic (Rippling button effect, dropdown animation, and etc...) is combined together and should be handled simultaneously. These logics have great potential to become separated from each other and make developers to focus on one logic each time. A great example of headache in TEA are UI libraries. Their goal is giving users nice implemented UI logics but because of TEA we cannot separate UI logic from business logic so developers should handle those logics by themself.

Summary

Monarch element is a solution for TEA to make separated UI logics possible alongside a pure functional architecture. In Monarch each document have an input and output which are the bridges for bidirectional communication with the outside of the document. Monarch element is a derivation of Monarch document which is responsible to translate DOM attributes and properties to input of the document and outputs of the document to DOM events. This translation mechanism makes an abstraction over web components and custom elements which has recently developed as a web standard by W3C.

Basic Example

Here I introduce a toggle custom HTML element named x-toggle as an example.

Usage

<x-toggle value="0">Label</x-toggle>
<x-toggle value="0" disabled>Label</x-toggle>
const toggle = document.createElement('x-toggle')

toggle.addEventListener('change', customEvent => {
  customEvent.detail // true | false
})

Implementation

type Model = { checked  :: Boolean
             , disabled :: Boolean
             , value :: String
             }

data Message = MonarchSentInput Input
             | UserToggledInput

type OptionalInput r = { disabled :: Boolean | r }

type Input = OptionalInput (value :: String)

type Output = Variant (OnChange ())

defaultInput :: OptionalInput ()
defaultInput = { disabled: false
               }

init :: Input -> Model
init = Record.merge { checked: false }

update :: Message -> Model -> Model
update message model = case message of
  UserToggledInput       -> model { checked = not model.checked }
  MonarchSentInput input -> Record.merge input model

type Slots = (label :: RequiredHtmlSlot)

view :: Model -> HostHtml Slots Message
view { checked, disabled } =
  host_ [ label { onClick: const UserToggledInput }
                [ input { checked
                        , disabled
                        , type: InputType.Radio
                        }
                , span_ [ slot' (SProxy "children") ]
                ]
        ]

command :: Message -> Model -> Command () Message Output Unit
command message model = case message of
  UserToggledInput -> raise $ onChange model.checked
  _                -> pure unit

subscription :: Upstream Input Model Message -> Event Message
subscription { eInput } = map MonarchSentInput eInput

type OnChange r = (onChange :: CustomEvent Boolean | r)

onChange :: forall r. Boolean -> Variant (OnChange r)
onChange detail =
  Monarch.mkCustomEvent { detail
                        , name: SProxy :: SProxy "onChange"
                        , bubbles: true
                        }

Details

Input

Inputs of a web component divides into two category:

Monarch deals differently with these concepts. A Monarch document have only one way to get information from outside which is input type. In element the input type is a record:

type Input = { a :: String
             , b :: Array String
             }

Imagine the above input type is for an element named x-element. Every field of the input record will be available as a property through DOM API of the element:

const element = document.createElement('x-element')

// Get
element.a
element.b

// Set
element.a = 'foo'
element.b = ['x', 'y']

But how Monarch decides which one of the input record fields can be an attribute? Based on the type of the field! If the type of the field be an instance of the Attributative typeclass, Monarch reflect it as an attribute too.

class Attributative a

instance stringAttributative :: Attributative String
instance numberAttributative :: Attributative Number
instance intAttributative :: Attributative Int
instance booleanAttributative :: Attributative Boolean

In the above example the a field can become an attribute because it's of type String which has an Attributative instance. But the b field can't because its type is Array String which doesn't have Attributative instance. So the x-element can be used in HTML like the following:

<x-element a="foo">

But the x-toggle implementation was not that simple! Let's look at it:

type OptionalInput r = { disabled :: Boolean | r }

type Input = OptionalInput (value :: String)

defaultInput :: OptionalInput ()
defaultInput = { disabled: false
               }

Simplified version of Input type for x-toggle is:

type Input = { disabled :: Boolean, value :: String }

As you maybe already guessed above input type leads into two DOM properties named disabled and value and two HTML attributes named disabled and value. There are some cases we want to provide a default value for some inputs and make them optional. In this case we want to make disabled input optional and define default false value for it. So we defined the optional part of the input into OptionalInput type which has default values in defaultInput. It's valuable to point out all of the required inputs should be provided before attachment of the element to the DOM. Otherwise it leads to an exception!

Model

Each element like a document has a model and message type:

type Model = { checked  :: Boolean
             , disabled :: Boolean
             , value :: String
             }

data Message = MonarchSentInput Input
             | UserToggledInput

The first model construction made upon the first input of the element through init function:

init :: Input -> Model
init = Record.merge { checked: false }

After that, developer can decide to respond to the input changes or not by subscribing to the input event eInput which is accessible in the Upstream of the subscriptions:

subscription :: Upstream Input Model Message -> Event Message
subscription { eInput } = map MonarchSentInput eInput

The model change only can take place in the update function.

update :: Message -> Model -> Model
update message model = case message of
  UserToggledInput       -> model { checked = not model.checked }
  MonarchSentInput input -> Record.merge input model

View

WIP

Output

WIP

Flags