├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ └── storybook.yml ├── .gitignore ├── .tidyrc.json ├── LICENSE ├── README.md ├── dist ├── css │ ├── global.css │ ├── index.css │ ├── pico.classless.min.css │ └── storybook.css └── index.html ├── example ├── Example │ ├── Basic.purs │ ├── CheckboxRadio.purs │ ├── DependentFields.purs │ ├── FileUpload.purs │ ├── LocalStorage.purs │ └── Utils │ │ ├── Field.purs │ │ ├── Types.purs │ │ └── Validation.purs ├── Main.purs └── example.dhall ├── package.json ├── packages.dhall ├── shell.nix ├── spago.dhall └── src └── Formless.purs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [thomashoneyman] 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - uses: purescript-contrib/setup-purescript@main 16 | with: 17 | purs-tidy: "latest" 18 | 19 | - name: Build source 20 | run: npm run build 21 | 22 | - name: Build examples 23 | run: npm run build:examples 24 | -------------------------------------------------------------------------------- /.github/workflows/storybook.yml: -------------------------------------------------------------------------------- 1 | name: Storybook Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: thomashoneyman/setup-purescript@main 14 | 15 | - name: Install esbuild 16 | run: npm install --global esbuild@0.13.12 17 | 18 | - name: Build Storybook 19 | run: npm run storybook 20 | 21 | - name: Deploy to GitHub Pages 22 | uses: peaceiris/actions-gh-pages@v3 23 | with: 24 | github_token: ${{ secrets.GITHUB_TOKEN }} 25 | publish_dir: ./dist 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !.gitignore 3 | !.github 4 | !.editorconfig 5 | !.tidyrc.json 6 | 7 | output 8 | generated-docs 9 | bower_components 10 | dist/app.js 11 | -------------------------------------------------------------------------------- /.tidyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "importSort": "ide", 3 | "importWrap": "source", 4 | "indent": 2, 5 | "operatorsFile": null, 6 | "ribbon": 1, 7 | "typeArrowPlacement": "first", 8 | "unicode": "never", 9 | "width": null 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Thomas Honeyman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Formless 2 | 3 | [![CI](https://github.com/thomashoneyman/purescript-halogen-formless/workflows/CI/badge.svg?branch=main)](https://github.com/thomashoneyman/purescript-halogen-formless/actions?query=workflow%3ACI+branch%3Amain) 4 | [![Latest release](http://img.shields.io/github/release/thomashoneyman/purescript-halogen-formless.svg)](https://github.com/thomashoneyman/purescript-halogen-formless/releases) 5 | [![Maintainer: thomashoneyman](https://img.shields.io/badge/maintainer-thomashoneyman-teal.svg)](http://github.com/thomashoneyman) 6 | 7 | Formless helps you write forms in Halogen without the boilerplate. 8 | 9 | - [Examples & documentation site](https://thomashoneyman.github.io/purescript-halogen-formless/) 10 | - [Source code for examples](./example) 11 | - [Migration of Real World Halogen from Formless 2 to Formless 3](https://github.com/thomashoneyman/purescript-halogen-realworld/pull/102) 12 | 13 | ## Installation 14 | 15 | Install Formless with Spago: 16 | 17 | ```console 18 | $ spago install halogen-formless 19 | ``` 20 | 21 | Formless 3 is available in package sets beginning with `psc-0.14.7-20220303`. If you are using a package set that does not include Formless, then you can add it to your local set as shown in the example below: 22 | 23 | ```dhall 24 | let upstream = ... 25 | 26 | in upstream 27 | with halogen-formless = 28 | { version = "v3.0.0" 29 | , repo = "https://github.com/thomashoneyman/purescript-halogen-formless.git" 30 | , dependencies = 31 | [ "convertable-options" 32 | , "effect" 33 | , "either" 34 | , "foldable-traversable" 35 | , "foreign-object" 36 | , "halogen" 37 | , "heterogeneous" 38 | , "maybe" 39 | , "prelude" 40 | , "record" 41 | , "safe-coerce" 42 | , "type-equality" 43 | , "unsafe-coerce" 44 | , "unsafe-reference" 45 | , "variant" 46 | , "web-events" 47 | , "web-uievents" 48 | ] 49 | } 50 | ``` 51 | 52 | ## Tutorial 53 | 54 | We're going to write a form from scratch, demonstrating how to use Formless with no helper functions. This tutorial can serve as the basis for your real applications, but you'll typically write your own helper functions for common form controls and validation in your app. Make sure to check out the [examples directory](./example) after you read this tutorial to expand your knowledge! 55 | 56 | Our form will let a user register their cat for pet insurance by recording its name, nickname, and age. Let's take the first step! 57 | 58 | ### Define a form type 59 | 60 | We'll start by defining a type for our form. 61 | 62 | ```purs 63 | type Form :: (Type -> Type -> Type -> Type) -> Row Type 64 | type Form f = 65 | ( name :: f String String String 66 | , nickname :: f String Void (Maybe String) 67 | , age :: f String String Int 68 | -- input error output 69 | ) 70 | ``` 71 | 72 | Form types are typically defined as a row of form fields, where each form field specifies its input, error, and output type as arguments to `f`. 73 | 74 | - The `input` type describes what the form field will receive from the user. For example, a text input will receive a `String`, while a radio group might use a custom sum type. 75 | - The `error` type describes what validation errors can occur for this form field. We'll stick to `String` for our example, but you can create your own form- or app-specific error types. 76 | - The `output` type describes what our input type will parse to, if it passes validation. For example, while we'll let the user type their cat's age into a text field and therefore accept a `String` as input, in our application we will only consider `Int` ages to be valid. 77 | 78 | Take a moment and think about what the input, error, and output types for each of our three fields are. Our `nickname` field has an output type of `Maybe String` -- what do you think that represents? 79 | 80 | Defining our form row this way provides maximum flexibility for defining other type synonyms in terms of the form row. This greatly reduces the amount of code you need to write for your form. For example, Formless requires that we provide an initial set of values for our form fields: 81 | 82 | ```purs 83 | initialValues = { name: "", nickname: "", age: "" } 84 | ``` 85 | 86 | We can write a type for this value by writing a brand new record type, or by reusing our form type: 87 | 88 | ```purs 89 | import Formless as F 90 | 91 | -- Option 1: Define a new record type 92 | type FormInputs = { name :: String, nickname :: String, age :: String } 93 | 94 | -- Option 2: Reuse our form row 95 | type FormInputs = { | Form F.FieldInput } 96 | ``` 97 | 98 | These two implementations of `FormInputs` are identical. However, reusing the form row requires less typing and ensures a single source of truth. 99 | 100 | ### Write component types 101 | 102 | Formless is a higher-order component, which means that it takes a component as an argument and returns a new component. The returned component can have any input, query, output, and monad types you wish -- Formless is entirely transparent from the perspective of a parent component. 103 | 104 | #### Public Types 105 | 106 | Let's write concrete types for our component's public interface. We don't need any input or to handle any queries, but we'll have our form raise a custom success message and a valid `Cat` as its output. 107 | 108 | ```purs 109 | -- Reusing our form row again! This type is identical to: 110 | -- { name :: String, nickname :: Maybe String, age :: Int } 111 | type Cat = { | Form F.FieldOutput } 112 | 113 | type Query = Const Void 114 | 115 | type Input = Unit 116 | 117 | type Output = { successMessage :: String, newCat :: Cat } 118 | 119 | -- We now have the types necessary for our wrapped component, 120 | -- which we'll run in `Aff`: 121 | component :: H.Component Query Input Output Aff 122 | ``` 123 | 124 | #### Internal Types 125 | 126 | Next, we'll turn to our internal component types: the state and action types (we don't need any child slots, so we'll hard code them to `()`). 127 | 128 | Formless requires our component to support two actions: 129 | 130 | - Your component must receive input of type `FormContext`, which includes the form fields and useful actions for controlling the form. It also includes any other input you want your component to take. By convention this action is called `Receive`. 131 | - Your component must raise actions of type `FormlessAction` to Formless for evaluation. By convention this action is called `Eval`. 132 | 133 | The `FormContext` and `FormlessAction` types you need to write for your `Action` type can be easily implemented by reusing your form row along with type synonyms provided by Formless. Let's define these two types for our form: 134 | 135 | ```purs 136 | -- Our form will receive `FormContext` as input. We can specialize the Formless- 137 | -- provided `F.FormContext` type to our form by giving it our form row applied 138 | -- to the `F.FieldState` and `F.FieldAction` type synonyms. 139 | -- 140 | -- The form context includes the current state of all fields in the form, so its 141 | -- first argument is our form row applied to `F.FieldState`. It also includes a 142 | -- set of actions for controlling the form, so our second argument is our form 143 | -- row and component action type applied to `F.FieldAction`. Finally, the form 144 | -- context passes through the input type we already defined for our component 145 | -- (in our case, `Unit`), and so it takes the `Input` type as its third 146 | -- argument. Finally, it provides some form-wide helper actions, and so we must 147 | -- provide our `Action` type as the fourth argument. 148 | type FormContext = F.FormContext (Form F.FieldState) (Form (F.FieldAction Action)) Input Action 149 | 150 | -- Our form raises Formless actions for evaluation, most of which track the 151 | -- state of a particular form field. We can specialize `F.FormlessAction` to our 152 | -- form by giving it our form row applied to the `F.FieldState` type synonym. 153 | type FormlessAction = F.FormlessAction (Form F.FieldState) 154 | ``` 155 | 156 | With our `FormContext` and `FormlessAction` types specialized, we can now implement our component's internal `Action` type: 157 | 158 | ```purs 159 | data Action 160 | = Receive FormContext 161 | | Eval FormlessAction 162 | ``` 163 | 164 | The `FormContext` and `FormlessAction` types can be confusing the first time you see them. If they are a lot to take in, don't worry: you'll get used to them, and after you define them once you don't have to touch them again (any changs you make to your form will happen on the form row). 165 | 166 | Our final component type is the `State` type. We don't need any extra state beyond what Formless gives us, so we'll just reuse the `FormContext` as our state type: 167 | 168 | ```purs 169 | type State = FormContext 170 | ``` 171 | 172 | ### Implement your form component 173 | 174 | We can now write our form component and make use of the state and helper functions that Formless makes available to us. 175 | 176 | You will typically implement your form component by applying Formless directly to `H.mkComponent`, which saves quite a bit of typing. The Formless higher-order component takes three arguments: 177 | 178 | - A `FormConfig`, which lets you control some of Formless' behavior, like when validation should be run, and lets you lift Formless actions into your `Action` type. The only required option is `liftAction`; all other fields are entirely optional. 179 | - A record of initial values for each field in your form. We already wrote an `initialValues` when we defined our form type, but since all our inputs are strings, we could also implement our initial form as a simple `mempty`. This is what's demonstrated below. 180 | - Your form component, which must accept `FormContext` as input, handle queries of type `FormQuery`, and raise outputs of type `FormOutput`. Don't worry -- we'll talk more about each of these! 181 | 182 | ```purs 183 | import Halogen as H 184 | import Effect.Aff (Aff) 185 | import Data.Maybe (Maybe(..)) 186 | 187 | form :: H.Component Query Input Output Aff 188 | form = F.formless { liftAction: Eval } mempty $ H.mkComponent 189 | { initialState: \context -> context 190 | , render 191 | , eval: H.mkEval $ H.defaultEval 192 | { receive = Just <<< Receive 193 | , handleAction = handleAction 194 | , handleQuery = handleQuery 195 | } 196 | } 197 | ``` 198 | 199 | #### Rendering Your Form 200 | 201 | The Formless form context provides you with the state of each field in your form, along with pre-made actions for handling change, blur, and other events. You can use this information to implement a basic form. 202 | 203 | In the below example, we make use of a form-wide action (`handleSubmit`), field-specific actions (`handleChange`, `handleBlur`), and field-specific state (`value`, `result`). 204 | 205 | ```purs 206 | form = F.formless ... 207 | where 208 | render :: FormContext -> H.ComponentHTML Action () Aff 209 | render { formActions, fields, actions } = 210 | HH.form 211 | [ HE.onSubmit formActions.handleSubmit ] 212 | [ HH.div_ 213 | [ HH.label_ 214 | [ HH.text "Name" ] 215 | , HH.input 216 | [ HP.type_ HP.InputText 217 | , HP.placeholder "Scooby" 218 | , HP.value fields.name.value 219 | , HE.onValueInput actions.name.handleChange 220 | , HE.onBlur actions.name.handleBlur 221 | ] 222 | -- We can use the `result` field to check if we have an error 223 | , case fields.name.result of 224 | Just (Left error) -> HH.text error 225 | _ -> HH.text "" 226 | ] 227 | ] 228 | ``` 229 | 230 | It's tedious and error-prone manually wiring up form fields, so most applications should define their own reusable form controls by abstracting what you see here. You can see examples of that in the [examples directory](./example). 231 | 232 | #### Handling Actions 233 | 234 | Every form component you provide to Formless should implement a `handleAction` function that updates your component when new form context is provided and tells Formless to evaluate form actions when they arise in your component. A typical `handleAction` function in a form component looks like this: 235 | 236 | ```purs 237 | form = F.formless ... 238 | where 239 | -- Here we've written out the full type signature for `handleAction`, but the 240 | -- compiler can infer these types for you if you would like to omit the type 241 | -- signature or provide `_` wildcards for lengthy types like `F.FormOutput`. 242 | -- 243 | -- Remember that our outer component has an output type of `Output`, but our 244 | -- inner component raises messages to Formless rather than to the form parent 245 | -- directly. We raise both our own output messages, `Output`, and also Formless 246 | -- actions that need to be evaluated. For that reason, we use the `F.FormOutput` 247 | -- output type for our inner component. 248 | handleAction 249 | :: Action 250 | -> H.HalogenM State Action () (F.FormOutput (Form F.FieldState) Output) Aff Unit 251 | handleAction = case _ of 252 | -- When we receive new form context we need to update our form state. 253 | Receive context -> 254 | H.put context 255 | 256 | -- When a `FormlessAction` has been triggered we must raise it up to 257 | -- Formless for evaluation. We can do this with `F.eval`. 258 | Eval action -> 259 | F.eval action 260 | ``` 261 | 262 | You can freely add your own actions to your form for anything else your form needs to do. See the [examples](./example) for...examples! 263 | 264 | #### Handling Queries 265 | 266 | Formless uses queries to notify your form component of important events like when a form is submitted or reset, or when a form field needs to be validated. 267 | 268 | Unlike previous versions of Formless, you don't provide any validation functions to the form directly. Instead, you will receive a `Validate` query that contains an input from your form. You are required to return an `Either error output` for that field back to Formless. 269 | 270 | The most important benefit of this approach is that you can write validation functions that run in the context of your form component. That means that your validators can freely access your form state, including the state of other fields in the form, and you can evaluate actions in your component as part of validation (for example, making a request or setting the value of another field). We'll just explore pure validation in this example, but the [examples directory](./example) demonstrates various validation scenarios. 271 | 272 | A typical `handleQuery` function uses the `handleSubmitValidate` or `handleSubmitValidateM` helper functions to only deal with form submission and validation events. In our case, we'll simply raise a successful form submission as output, and we'll provide a set of pure validation functions: 273 | 274 | ```purs 275 | form = F.formless ... 276 | where 277 | -- Here we'll use wildcards rather than type everything out; the compiler is 278 | -- able to infer these types for us. 279 | handleQuery :: forall a. F.FormQuery _ _ _ _ a -> H.HalogenM _ _ _ _ _ (Maybe a) 280 | handleQuery = do 281 | let 282 | -- These validators would usually be in a separate validation module in 283 | -- your app rather than be defined inline like this. 284 | validateName :: String -> Either String String 285 | validateName input 286 | | input == "" = Left "Required" 287 | | otherwise = Right input 288 | 289 | validateNickname :: String -> Either Void (Maybe String) 290 | validateNickname input 291 | | input == "" = Right Nothing 292 | | otherwise = Right (Just input) 293 | 294 | validateAge :: String -> Either String Int 295 | validateAge input = case Int.fromString input of 296 | Nothing -> Left "Not a valid integer." 297 | Just n 298 | | n > 20 -> Left "No dog is over 20 years old!" 299 | | n <= 0 -> Left "No dog is less than 0 years old!" 300 | | otherwise -> Right n 301 | 302 | validation :: { | Form F.FieldValidation } 303 | validation = 304 | { name: validateName 305 | , nickname: validateNickname 306 | , age: validateAge 307 | } 308 | 309 | handleSuccess :: Cat -> H.HalogenM _ _ _ _ _ Unit 310 | handleSuccess cat = do 311 | let 312 | output :: Output 313 | output = { successMessage: "Got a cat!", newCat: cat } 314 | 315 | -- F.raise is a helper function for raising your `Output` type through 316 | -- Formless and up to the parent component. 317 | F.raise output 318 | 319 | -- handleSubmitValidate lets you provide a success handler and a record 320 | -- of validation functions to handle submission and validation events. 321 | F.handleSubmitValidate handleSuccess F.validate validation 322 | ``` 323 | 324 | In a typical form, you wouldn't write out all these types, and your validation functions would probably live in a separate `Validation` module in your project. In the real world, a more typical `handleQuery` looks like this: 325 | 326 | ```purs 327 | import MyApp.Validation as V 328 | 329 | form = F.formless ... 330 | where 331 | handleQuery :: forall a. F.FormQuery _ _ _ _ a -> H.HalogenM _ _ _ _ _ (Maybe a) 332 | handleQuery = F.handleSubmitValidate F.raise F.validate 333 | { name: V.required 334 | , nickname: V.optional 335 | , age: V.int >=> V.greaterThan 0 >=> V.lessThan 20 336 | } 337 | ``` 338 | 339 | If you would like to see all possible events that your `handleQuery` function can handle, please see the implementation of `handleSubmitValidate`. 340 | 341 | ## Comments & Improvements 342 | 343 | Have any comments about the library or any ideas to improve it for your use case? Please file an issue, or reach out on the [PureScript forum](https://discourse.purescript.org) or [PureScript chat](https://purescript.org/chat). 344 | -------------------------------------------------------------------------------- /dist/css/global.css: -------------------------------------------------------------------------------- 1 | *, *::before, *::after { 2 | box-sizing: border-box; 3 | } 4 | 5 | * { 6 | margin: 0; 7 | } 8 | 9 | html, body { 10 | height: 100%; 11 | } 12 | 13 | body { 14 | line-height: 1.5; 15 | -webkit-font-smoothing: antialiased; 16 | } 17 | 18 | img, picture, video, canvas, svg { 19 | display: block; 20 | max-width: 100%; 21 | } 22 | 23 | input, button, textarea, select { 24 | font: inherit; 25 | } 26 | 27 | p, h1, h2, h3, h4, h5, h6 { 28 | overflow-wrap: break-word; 29 | } 30 | 31 | #root, #__next { 32 | isolation: isolate; 33 | } -------------------------------------------------------------------------------- /dist/css/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --font-size: 18px; 3 | } 4 | 5 | body { 6 | font-family: sans-serif; 7 | } 8 | 9 | article { 10 | max-width: 800px; 11 | margin-right: auto; 12 | margin-left: auto; 13 | } 14 | 15 | .Storybook-nav-list a:focus { 16 | background-color: inherit; 17 | } 18 | -------------------------------------------------------------------------------- /dist/css/pico.classless.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Pico.css v1.4.1 (https://picocss.com) 3 | * Copyright 2019-2021 - Licensed under MIT 4 | */:root{--font-family:system-ui,-apple-system,"Segoe UI","Roboto","Ubuntu","Cantarell","Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--line-height:1.5;--font-weight:400;--font-size:16px;--border-radius:0.25rem;--border-width:1px;--outline-width:3px;--spacing:1rem;--typography-spacing-vertical:1.5rem;--block-spacing-vertical:calc(var(--spacing) * 2);--block-spacing-horizontal:var(--spacing);--grid-spacing-vertical:0;--grid-spacing-horizontal:var(--spacing);--form-element-spacing-vertical:0.75rem;--form-element-spacing-horizontal:1rem;--transition:0.2s ease-in-out}@media (min-width:576px){:root{--font-size:17px}}@media (min-width:768px){:root{--font-size:18px}}@media (min-width:992px){:root{--font-size:19px}}@media (min-width:1200px){:root{--font-size:20px}}@media (min-width:576px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 2.5)}}@media (min-width:768px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 3)}}@media (min-width:992px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 3.5)}}@media (min-width:1200px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 4)}}@media (min-width:576px){article{--block-spacing-horizontal:calc(var(--spacing) * 1.25)}}@media (min-width:768px){article{--block-spacing-horizontal:calc(var(--spacing) * 1.5)}}@media (min-width:992px){article{--block-spacing-horizontal:calc(var(--spacing) * 1.75)}}@media (min-width:1200px){article{--block-spacing-horizontal:calc(var(--spacing) * 2)}}a{--text-decoration:none}a.contrast,a.secondary{--text-decoration:underline}small{--font-size:0.875em}h1,h2,h3,h4,h5,h6{--font-weight:700}h1{--font-size:2rem;--typography-spacing-vertical:3rem}h2{--font-size:1.75rem;--typography-spacing-vertical:2.625rem}h3{--font-size:1.5rem;--typography-spacing-vertical:2.25rem}h4{--font-size:1.25rem;--typography-spacing-vertical:1.874rem}h5{--font-size:1.125rem;--typography-spacing-vertical:1.6875rem}[type=checkbox],[type=radio]{--border-width:2px}[type=checkbox][role=switch]{--border-width:3px}thead td,thead th{--border-width:3px}:not(thead)>*>td{--font-size:0.875em}code,kbd,pre,samp{--font-family:"Menlo","Consolas","Roboto Mono","Ubuntu Monospace","Noto Mono","Oxygen Mono","Liberation Mono",monospace,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"}kbd{--font-weight:bolder}:root:not([data-theme=dark]),[data-theme=light]{color-scheme:light;--background-color:#fff;--color:#415462;--h1-color:#1b2832;--h2-color:#24333e;--h3-color:#2c3d49;--h4-color:#374956;--h5-color:#415462;--h6-color:#4d606d;--muted-color:#73828c;--muted-border-color:#edf0f3;--primary:#1095c1;--primary-hover:#08769b;--primary-focus:rgba(16, 149, 193, 0.125);--primary-inverse:#fff;--secondary:#596b78;--secondary-hover:#415462;--secondary-focus:rgba(89, 107, 120, 0.125);--secondary-inverse:#fff;--contrast:#1b2832;--contrast-hover:#000;--contrast-focus:rgba(89, 107, 120, 0.125);--contrast-inverse:#fff;--mark-background-color:#fff2ca;--mark-color:#543a26;--ins-color:#388e3c;--del-color:#c62828;--blockquote-border-color:var(--muted-border-color);--blockquote-footer-color:var(--muted-color);--button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--form-element-background-color:transparent;--form-element-border-color:#a2afb9;--form-element-color:var(--color);--form-element-placeholder-color:var(--muted-color);--form-element-active-background-color:transparent;--form-element-active-border-color:var(--primary);--form-element-focus-color:var(--primary-focus);--form-element-disabled-background-color:#d5dce2;--form-element-disabled-border-color:#a2afb9;--form-element-disabled-opacity:0.5;--form-element-invalid-border-color:#c62828;--form-element-invalid-active-border-color:#b71c1c;--form-element-valid-border-color:#388e3c;--form-element-valid-active-border-color:#2e7d32;--switch-background-color:#bbc6ce;--switch-color:var(--primary-inverse);--switch-checked-background-color:var(--primary);--range-border-color:#d5dce2;--range-active-border-color:#bbc6ce;--range-thumb-border-color:var(--background-color);--range-thumb-color:var(--secondary);--range-thumb-hover-color:var(--secondary-hover);--range-thumb-active-color:var(--primary);--table-border-color:var(--muted-border-color);--table-row-stripped-background-color:#f6f8f9;--code-background-color:#edf0f3;--code-color:var(--muted-color);--code-kbd-background-color:var(--contrast);--code-kbd-color:var(--contrast-inverse);--code-tag-color:#b34d80;--code-property-color:#3d888f;--code-value-color:#998866;--code-comment-color:#a2afb9;--accordion-border-color:var(--muted-border-color);--accordion-close-summary-color:var(--color);--accordion-open-summary-color:var(--muted-color);--card-background-color:var(--background-color);--card-border-color:var(--muted-border-color);--card-box-shadow:0 0.125rem 1rem rgba(27, 40, 50, 0.04),0 0.125rem 2rem rgba(27, 40, 50, 0.08),0 0 0 0.0625rem rgba(27, 40, 50, 0.024);--card-sectionning-background-color:#fbfbfc;--progress-background-color:#d5dce2;--progress-color:var(--primary);--loading-spinner-opacity:0.5;--tooltip-background-color:var(--contrast);--tooltip-color:var(--contrast-inverse);--icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(65, 84, 98, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(65, 84, 98, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(65, 84, 98, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(65, 84, 98, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23FFF' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23FFF' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(56, 142, 60, 0.999)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(198, 40, 40, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}@media only screen and (prefers-color-scheme:dark){:root:not([data-theme=light]){color-scheme:dark;--background-color:#11191f;--color:#bbc6ce;--h1-color:#edf0f3;--h2-color:#e1e6eb;--h3-color:#d5dce2;--h4-color:#c8d1d8;--h5-color:#bbc6ce;--h6-color:#afbbc4;--muted-color:#73828c;--muted-border-color:#1f2d38;--primary:#1095c1;--primary-hover:#1ab3e6;--primary-focus:rgba(16, 149, 193, 0.25);--primary-inverse:#fff;--secondary:#596b78;--secondary-hover:#73828c;--secondary-focus:rgba(115, 130, 140, 0.25);--secondary-inverse:#fff;--contrast:#edf0f3;--contrast-hover:#fff;--contrast-focus:rgba(115, 130, 140, 0.25);--contrast-inverse:#000;--mark-background-color:#d1c284;--mark-color:#11191f;--ins-color:#388e3c;--del-color:#c62828;--blockquote-border-color:var(--muted-border-color);--blockquote-footer-color:var(--muted-color);--button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--form-element-background-color:#11191f;--form-element-border-color:#374956;--form-element-color:var(--color);--form-element-placeholder-color:var(--muted-color);--form-element-active-background-color:var(--form-element-background-color);--form-element-active-border-color:var(--primary);--form-element-focus-color:var(--primary-focus);--form-element-disabled-background-color:#2c3d49;--form-element-disabled-border-color:#415462;--form-element-disabled-opacity:0.5;--form-element-invalid-border-color:#b71c1c;--form-element-invalid-active-border-color:#c62828;--form-element-valid-border-color:#2e7d32;--form-element-valid-active-border-color:#388e3c;--switch-background-color:#374956;--switch-color:var(--primary-inverse);--switch-checked-background-color:var(--primary);--range-border-color:#24333e;--range-active-border-color:#2c3d49;--range-thumb-border-color:var(--background-color);--range-thumb-color:var(--secondary);--range-thumb-hover-color:var(--secondary-hover);--range-thumb-active-color:var(--primary);--table-border-color:var(--muted-border-color);--table-row-stripped-background-color:rgba(115, 130, 140, 0.05);--code-background-color:#18232c;--code-color:var(--muted-color);--code-kbd-background-color:var(--contrast);--code-kbd-color:var(--contrast-inverse);--code-tag-color:#a65980;--code-property-color:#599fa6;--code-value-color:#8c8473;--code-comment-color:#4d606d;--accordion-border-color:var(--muted-border-color);--accordion-active-summary-color:var(--primary);--accordion-close-summary-color:var(--color);--accordion-open-summary-color:var(--muted-color);--card-background-color:#141e26;--card-border-color:#11191f;--card-box-shadow:0 0.125rem 1rem rgba(0, 0, 0, 0.06),0 0.125rem 2rem rgba(0, 0, 0, 0.12),0 0 0 0.0625rem rgba(0, 0, 0, 0.036);--card-sectionning-background-color:#18232c;--progress-background-color:#24333e;--progress-color:var(--primary);--loading-spinner-opacity:0.5;--tooltip-background-color:var(--contrast);--tooltip-color:var(--contrast-inverse);--icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23FFF' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23FFF' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(46, 125, 50, 0.999)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(183, 28, 28, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}}[data-theme=dark]{color-scheme:dark;--background-color:#11191f;--color:#bbc6ce;--h1-color:#edf0f3;--h2-color:#e1e6eb;--h3-color:#d5dce2;--h4-color:#c8d1d8;--h5-color:#bbc6ce;--h6-color:#afbbc4;--muted-color:#73828c;--muted-border-color:#1f2d38;--primary:#1095c1;--primary-hover:#1ab3e6;--primary-focus:rgba(16, 149, 193, 0.25);--primary-inverse:#fff;--secondary:#596b78;--secondary-hover:#73828c;--secondary-focus:rgba(115, 130, 140, 0.25);--secondary-inverse:#fff;--contrast:#edf0f3;--contrast-hover:#fff;--contrast-focus:rgba(115, 130, 140, 0.25);--contrast-inverse:#000;--mark-background-color:#d1c284;--mark-color:#11191f;--ins-color:#388e3c;--del-color:#c62828;--blockquote-border-color:var(--muted-border-color);--blockquote-footer-color:var(--muted-color);--button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--form-element-background-color:#11191f;--form-element-border-color:#374956;--form-element-color:var(--color);--form-element-placeholder-color:var(--muted-color);--form-element-active-background-color:var(--form-element-background-color);--form-element-active-border-color:var(--primary);--form-element-focus-color:var(--primary-focus);--form-element-disabled-background-color:#2c3d49;--form-element-disabled-border-color:#415462;--form-element-disabled-opacity:0.5;--form-element-invalid-border-color:#b71c1c;--form-element-invalid-active-border-color:#c62828;--form-element-valid-border-color:#2e7d32;--form-element-valid-active-border-color:#388e3c;--switch-background-color:#374956;--switch-color:var(--primary-inverse);--switch-checked-background-color:var(--primary);--range-border-color:#24333e;--range-active-border-color:#2c3d49;--range-thumb-border-color:var(--background-color);--range-thumb-color:var(--secondary);--range-thumb-hover-color:var(--secondary-hover);--range-thumb-active-color:var(--primary);--table-border-color:var(--muted-border-color);--table-row-stripped-background-color:rgba(115, 130, 140, 0.05);--code-background-color:#18232c;--code-color:var(--muted-color);--code-kbd-background-color:var(--contrast);--code-kbd-color:var(--contrast-inverse);--code-tag-color:#a65980;--code-property-color:#599fa6;--code-value-color:#8c8473;--code-comment-color:#4d606d;--accordion-border-color:var(--muted-border-color);--accordion-active-summary-color:var(--primary);--accordion-close-summary-color:var(--color);--accordion-open-summary-color:var(--muted-color);--card-background-color:#141e26;--card-border-color:#11191f;--card-box-shadow:0 0.125rem 1rem rgba(0, 0, 0, 0.06),0 0.125rem 2rem rgba(0, 0, 0, 0.12),0 0 0 0.0625rem rgba(0, 0, 0, 0.036);--card-sectionning-background-color:#18232c;--progress-background-color:#24333e;--progress-color:var(--primary);--loading-spinner-opacity:0.5;--tooltip-background-color:var(--contrast);--tooltip-color:var(--contrast-inverse);--icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23FFF' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23FFF' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(46, 125, 50, 0.999)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(183, 28, 28, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}*,::after,::before{box-sizing:border-box}::after,::before{text-decoration:inherit;vertical-align:inherit}html{-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-ms-text-size-adjust:100%;background-color:var(--background-color);color:var(--color);font-family:var(--font-family);font-size:var(--font-size);font-weight:var(--font-weight);line-height:var(--line-height);text-rendering:optimizeLegibility;cursor:default}main{display:block}body{width:100%;margin:0}body>footer,body>header,body>main{width:100%;margin-right:auto;margin-left:auto;padding:var(--block-spacing-vertical) var(--block-spacing-horizontal)}@media (min-width:576px){body>footer,body>header,body>main{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){body>footer,body>header,body>main{max-width:700px}}@media (min-width:992px){body>footer,body>header,body>main{max-width:920px}}@media (min-width:1200px){body>footer,body>header,body>main{max-width:1130px}}section{margin-bottom:var(--block-spacing-vertical)}figure{display:block;margin:0;padding:0;overflow-x:auto}figure figcaption{padding:calc(var(--spacing) * .5) 0;color:var(--muted-color)}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}dl dl,dl ol,dl ul,ol dl,ul dl{margin:0}ol ol,ol ul,ul ol,ul ul{margin:0}address,blockquote,dl,figure,form,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--typography-spacing-vertical);color:var(--color);font-size:var(--font-size);font-weight:var(--font-weight);font-style:normal}a{--color:var(--primary);--background-color:transparent;outline:0;background-color:var(--background-color);color:var(--color);-webkit-text-decoration:var(--text-decoration);text-decoration:var(--text-decoration);transition:background-color var(--transition),color var(--transition),box-shadow var(--transition),-webkit-text-decoration var(--transition);transition:background-color var(--transition),color var(--transition),text-decoration var(--transition),box-shadow var(--transition);transition:background-color var(--transition),color var(--transition),text-decoration var(--transition),box-shadow var(--transition),-webkit-text-decoration var(--transition)}a:active,a:focus,a:hover{--color:var(--primary-hover);--text-decoration:underline}a:focus{--background-color:var(--primary-focus)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--typography-spacing-vertical);color:var(--color);font-family:var(--font-family);font-size:var(--font-size);font-weight:var(--font-weight)}h1{--color:var(--h1-color)}h2{--color:var(--h2-color)}h3{--color:var(--h3-color)}h4{--color:var(--h4-color)}h5{--color:var(--h5-color)}h6{--color:var(--h6-color)}address~h1,address~h2,address~h3,address~h4,address~h5,address~h6,blockquote~h1,blockquote~h2,blockquote~h3,blockquote~h4,blockquote~h5,blockquote~h6,dl~h1,dl~h2,dl~h3,dl~h4,dl~h5,dl~h6,figure~h1,figure~h2,figure~h3,figure~h4,figure~h5,figure~h6,form~h1,form~h2,form~h3,form~h4,form~h5,form~h6,ol~h1,ol~h2,ol~h3,ol~h4,ol~h5,ol~h6,pre~h1,pre~h2,pre~h3,pre~h4,pre~h5,pre~h6,p~h1,p~h2,p~h3,p~h4,p~h5,p~h6,table~h1,table~h2,table~h3,table~h4,table~h5,table~h6,ul~h1,ul~h2,ul~h3,ul~h4,ul~h5,ul~h6{margin-top:var(--typography-spacing-vertical)}hgroup{margin-bottom:var(--typography-spacing-vertical)}hgroup>*{margin-bottom:0}hgroup>:last-child{--color:var(--muted-color);--font-weight:unset;font-family:unset;font-size:1rem}p{margin-bottom:var(--typography-spacing-vertical)}small{font-size:var(--font-size)}ol,ul{padding-right:0;padding-left:var(--spacing);-webkit-padding-end:0;padding-inline-end:0;-webkit-padding-start:var(--spacing);padding-inline-start:var(--spacing)}ol li,ul li{margin-bottom:calc(var(--typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--mark-background-color);color:var(--mark-color);vertical-align:middle}blockquote{display:block;margin:var(--typography-spacing-vertical) 0;padding:var(--spacing);border-right:none;border-left:.25rem solid var(--blockquote-border-color);-webkit-border-end:none;border-inline-end:none;-webkit-border-start:0.25rem solid var(--blockquote-border-color);border-inline-start:0.25rem solid var(--blockquote-border-color)}blockquote footer{margin-top:calc(var(--typography-spacing-vertical) * .5);color:var(--blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--ins-color);text-decoration:none}del{color:var(--del-color)}::-moz-selection{background-color:var(--primary-focus)}::selection{background-color:var(--primary-focus)}audio,canvas,iframe,img,svg,video{vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}iframe{border-style:none}img{max-width:100%;height:auto;border-style:none}svg:not([fill]){fill:currentColor}svg:not(:root){overflow:hidden}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}button{display:block;width:100%;margin-bottom:var(--spacing)}a[role=button]{display:inline-block;text-decoration:none}a[role=button],button,input[type=button],input[type=reset],input[type=submit]{--background-color:var(--primary);--border-color:var(--primary);--color:var(--primary-inverse);--box-shadow:var(--button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--form-element-spacing-vertical) var(--form-element-spacing-horizontal);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-size:1rem;font-weight:var(--font-weight);line-height:var(--line-height);text-align:center;cursor:pointer;transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}a[role=button]:active,a[role=button]:focus,a[role=button]:hover,button:active,button:focus,button:hover,input[type=button]:active,input[type=button]:focus,input[type=button]:hover,input[type=reset]:active,input[type=reset]:focus,input[type=reset]:hover,input[type=submit]:active,input[type=submit]:focus,input[type=submit]:hover{--background-color:var(--primary-hover);--border-color:var(--primary-hover);--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0))}a[role=button]:focus,button:focus,input[type=button]:focus,input[type=reset]:focus,input[type=submit]:focus{--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--outline-width) var(--primary-focus)}input[type=reset]{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);cursor:pointer}input[type=reset]:active,input[type=reset]:focus,input[type=reset]:hover{--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}input[type=reset]:focus{--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--outline-width) var(--secondary-focus)}a[role=button][disabled],button[disabled],input[type=button][disabled],input[type=reset][disabled],input[type=submit][disabled]{opacity:.5;pointer-events:none}input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:1rem;letter-spacing:inherit;line-height:var(--line-height)}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox]):not([type=radio]):not([type=range]){height:calc(1rem * var(--line-height) + var(--form-element-spacing-vertical) * 2 + var(--border-width) * 2)}fieldset{margin:0;margin-bottom:var(--spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--spacing) * .25)}input:not([type=checkbox]):not([type=radio]),select,textarea{width:100%}input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--form-element-spacing-vertical) var(--form-element-spacing-horizontal);vertical-align:middle}input,select,textarea{--background-color:var(--form-element-background-color);--border-color:var(--form-element-border-color);--color:var(--form-element-color);--box-shadow:none;border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}input:not([type=submit]):not([type=button]):not([type=reset]):not([type=checkbox]):not([type=radio]):not([readonly]):active,input:not([type=submit]):not([type=button]):not([type=reset]):not([type=checkbox]):not([type=radio]):not([readonly]):focus,select:active,select:focus,textarea:active,textarea:focus{--background-color:var(--form-element-active-background-color)}input:not([type=submit]):not([type=button]):not([type=reset]):not([role=switch]):not([readonly]):active,input:not([type=submit]):not([type=button]):not([type=reset]):not([role=switch]):not([readonly]):focus,select:active,select:focus,textarea:active,textarea:focus{--border-color:var(--form-element-active-border-color)}input:not([type=submit]):not([type=button]):not([type=reset]):not([type=range]):not([type=file]):not([readonly]):focus,select:focus,textarea:focus{--box-shadow:0 0 0 var(--outline-width) var(--form-element-focus-color)}input:not([type=submit]):not([type=button]):not([type=reset])[disabled],select[disabled],textarea[disabled]{--background-color:var(--form-element-disabled-background-color);--border-color:var(--form-element-disabled-border-color);opacity:var(--form-element-disabled-opacity)}input:not([type=checkbox]):not([type=radio])[aria-invalid],select:not([type=checkbox]):not([type=radio])[aria-invalid],textarea:not([type=checkbox]):not([type=radio])[aria-invalid]{padding-right:calc(var(--form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--form-element-spacing-horizontal);-webkit-padding-end:calc(var(--form-element-spacing-horizontal) + 1.5rem)!important;padding-inline-end:calc(var(--form-element-spacing-horizontal) + 1.5rem)!important;-webkit-padding-start:var(--form-element-spacing-horizontal)!important;padding-inline-start:var(--form-element-spacing-horizontal)!important;background-position:center right .75rem;background-repeat:no-repeat;background-size:1rem auto}input:not([type=checkbox]):not([type=radio])[aria-invalid=false],select:not([type=checkbox]):not([type=radio])[aria-invalid=false],textarea:not([type=checkbox]):not([type=radio])[aria-invalid=false]{background-image:var(--icon-valid)}input:not([type=checkbox]):not([type=radio])[aria-invalid=true],select:not([type=checkbox]):not([type=radio])[aria-invalid=true],textarea:not([type=checkbox]):not([type=radio])[aria-invalid=true]{background-image:var(--icon-invalid)}input[aria-invalid=false],select[aria-invalid=false],textarea[aria-invalid=false]{--border-color:var(--form-element-valid-border-color)}input[aria-invalid=false]:active,input[aria-invalid=false]:focus,select[aria-invalid=false]:active,select[aria-invalid=false]:focus,textarea[aria-invalid=false]:active,textarea[aria-invalid=false]:focus{--border-color:var(--form-element-valid-active-border-color)!important}input[aria-invalid=true],select[aria-invalid=true],textarea[aria-invalid=true]{--border-color:var(--form-element-invalid-border-color)}input[aria-invalid=true]:active,input[aria-invalid=true]:focus,select[aria-invalid=true]:active,select[aria-invalid=true]:focus,textarea[aria-invalid=true]:active,textarea[aria-invalid=true]:focus{--border-color:var(--form-element-invalid-active-border-color)!important}[dir=rtl] input[aria-invalid],[dir=rtl] select[aria-invalid],[dir=rtl] textarea[aria-invalid]{background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--form-element-placeholder-color);opacity:1}input:not([type=checkbox]):not([type=radio]),select,textarea{margin-bottom:var(--spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple]):not([size]){padding-right:calc(var(--form-element-spacing-horizontal) + 1.5rem);padding-left:var(--form-element-spacing-horizontal);-webkit-padding-end:calc(var(--form-element-spacing-horizontal) + 1.5rem);padding-inline-end:calc(var(--form-element-spacing-horizontal) + 1.5rem);-webkit-padding-start:var(--form-element-spacing-horizontal);padding-inline-start:var(--form-element-spacing-horizontal);background-image:var(--icon-chevron);background-position:center right .75rem;background-repeat:no-repeat;background-size:1rem auto}[dir=rtl] select:not([multiple]):not([size]){background-position:center left .75rem}input+small,select+small,textarea+small{display:block;width:100%;margin-top:calc(var(--spacing) * -.75);margin-bottom:var(--spacing);color:var(--muted-color)}label>input,label>select,label>textarea{margin-top:calc(var(--spacing) * .25)}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-right:.375em;margin-left:0;-webkit-margin-end:.375em;margin-inline-end:.375em;-webkit-margin-start:0;margin-inline-start:0;border-width:var(--border-width);vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--background-color:var(--primary);--border-color:var(--primary);background-image:var(--icon-checkbox);background-position:center;background-repeat:no-repeat;background-size:.75em auto}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-right:.375em;margin-bottom:0;cursor:pointer}[type=checkbox]:indeterminate{--background-color:var(--primary);--border-color:var(--primary);background-image:var(--icon-minus);background-position:center;background-repeat:no-repeat;background-size:.75em auto}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--background-color:var(--primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--background-color:var(--switch-background-color);--border-color:var(--switch-background-color);--color:var(--switch-color);width:2.25em;height:1.25em;border:var(--border-width) solid var(--border-color);border-radius:1.25em;background-color:var(--background-color);line-height:1.25em}[type=checkbox][role=switch]:focus{--background-color:var(--switch-background-color);--border-color:var(--switch-background-color)}[type=checkbox][role=switch]:checked{--background-color:var(--switch-checked-background-color);--border-color:var(--switch-checked-background-color)}[type=checkbox][role=switch]:before{display:block;width:calc(1.25em - (var(--border-width) * 2));height:100%;border-radius:50%;background-color:var(--color);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:checked{background-image:none}[type=checkbox][role=switch]:checked::before{margin-right:0;margin-left:calc(1.125em - var(--border-width));-webkit-margin-end:0;margin-inline-end:0;-webkit-margin-start:calc(1.125em - var(--border-width));margin-inline-start:calc(1.125em - var(--border-width))}[type=checkbox][role=switch][aria-invalid=false]{--border-color:var(--form-element-valid-border-color)}[type=checkbox][role=switch][aria-invalid=false]:active,[type=checkbox][role=switch][aria-invalid=false]:focus{--border-color:var(--form-element-valid-active-border-color)!important}[type=checkbox][role=switch][aria-invalid=true]{--border-color:var(--form-element-invalid-border-color)}[type=checkbox][role=switch][aria-invalid=true]:active,[type=checkbox][role=switch][aria-invalid=true]:focus{--border-color:var(--form-element-invalid-active-border-color)!important}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:none;border-radius:calc(var(--border-radius) * .5)}[type=color]::-moz-color-swatch{border:none;border-radius:calc(var(--border-radius) * .5)}:not(:dir(rtl)) [type=date],:not(:dir(rtl)) [type=datetime-local],:not(:dir(rtl)) [type=month],:not(:dir(rtl)) [type=time],:not(:dir(rtl)) [type=week]{background-image:var(--icon-date);background-position:center right .75rem;background-repeat:no-repeat;background-size:1rem auto}:not(:dir(rtl)) [type=date]::-webkit-calendar-picker-indicator,:not(:dir(rtl)) [type=datetime-local]::-webkit-calendar-picker-indicator,:not(:dir(rtl)) [type=month]::-webkit-calendar-picker-indicator,:not(:dir(rtl)) [type=time]::-webkit-calendar-picker-indicator,:not(:dir(rtl)) [type=week]::-webkit-calendar-picker-indicator{opacity:0}:not(:dir(rtl)) [type=time]{background-image:var(--icon-time)}[type=file]{--color:var(--muted-color);padding:calc(var(--form-element-spacing-vertical) * .5) 0;border:none;border-radius:0;background:0 0}[type=file]:active,[type=file]:focus,[type=file]:hover{border:none;background:0 0}[type=file]::-webkit-file-upload-button{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;-webkit-margin-end:calc(var(--spacing)/ 2);margin-inline-end:calc(var(--spacing)/ 2);-webkit-margin-start:0;margin-inline-start:0;padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-size:1rem;font-weight:var(--font-weight);line-height:var(--line-height);text-align:center;cursor:pointer;-webkit-transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::file-selector-button{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;-webkit-margin-end:calc(var(--spacing)/ 2);margin-inline-end:calc(var(--spacing)/ 2);-webkit-margin-start:0;margin-inline-start:0;padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-size:1rem;font-weight:var(--font-weight);line-height:var(--line-height);text-align:center;cursor:pointer;transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::-webkit-file-upload-button:active,[type=file]::-webkit-file-upload-button:focus,[type=file]::-webkit-file-upload-button:hover{--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=file]::file-selector-button:active,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:hover{--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=file]::-webkit-file-upload-button{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;-webkit-margin-end:calc(var(--spacing)/ 2);margin-inline-end:calc(var(--spacing)/ 2);-webkit-margin-start:0;margin-inline-start:0;padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-size:1rem;font-weight:var(--font-weight);line-height:var(--line-height);text-align:center;cursor:pointer;-webkit-transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::-webkit-file-upload-button:active,[type=file]::-webkit-file-upload-button:focus,[type=file]::-webkit-file-upload-button:hover{--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=file]::-ms-browse{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;margin-inline-end:calc(var(--spacing)/ 2);margin-inline-start:0;padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-size:1rem;font-weight:var(--font-weight);line-height:var(--line-height);text-align:center;cursor:pointer;-ms-transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::-ms-browse:active,[type=file]::-ms-browse:focus,[type=file]::-ms-browse:hover{--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.25rem;border-radius:var(--border-radius);background-color:var(--range-border-color);-webkit-transition:background-color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),box-shadow var(--transition)}[type=range]::-moz-range-track{width:100%;height:.25rem;border-radius:var(--border-radius);background-color:var(--range-border-color);-moz-transition:background-color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),box-shadow var(--transition)}[type=range]::-ms-track{width:100%;height:.25rem;border-radius:var(--border-radius);background-color:var(--range-border-color);-ms-transition:background-color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),box-shadow var(--transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.5rem;border:2px solid var(--range-thumb-border-color);border-radius:50%;background-color:var(--range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--transition),transform var(--transition);transition:background-color var(--transition),transform var(--transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.5rem;border:2px solid var(--range-thumb-border-color);border-radius:50%;background-color:var(--range-thumb-color);cursor:pointer;-moz-transition:background-color var(--transition),transform var(--transition);transition:background-color var(--transition),transform var(--transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.5rem;border:2px solid var(--range-thumb-border-color);border-radius:50%;background-color:var(--range-thumb-color);cursor:pointer;-ms-transition:background-color var(--transition),transform var(--transition);transition:background-color var(--transition),transform var(--transition)}[type=range]:focus,[type=range]:hover{--range-border-color:var(--range-active-border-color);--range-thumb-color:var(--range-thumb-hover-color)}[type=range]:active{--range-thumb-color:var(--range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}[type=search]{border-radius:5rem;padding-left:calc(var(--form-element-spacing-horizontal) + 1.75rem)!important;background-image:var(--icon-search);background-position:center left 1.125rem;background-repeat:no-repeat;background-size:1rem auto}[type=search]::-webkit-search-cancel-button{-webkit-appearance:none;display:none}table{width:100%;border-color:inherit;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--spacing)/ 2) var(--spacing);border-bottom:var(--border-width) solid var(--table-border-color);color:var(--color);font-size:var(--font-size);font-weight:var(--font-weight);text-align:left;text-align:start}tr{background-color:var(--background-color)}table[role=grid] tbody tr:nth-child(odd){--background-color:var(--table-row-stripped-background-color)}code,kbd,pre,samp{font-family:var(--font-family);font-size:.875em}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre{background:var(--code-background-color);color:var(--code-color);font-weight:var(--font-weight);line-height:initial;border-radius:var(--border-radius)}code,kbd{display:inline-block;padding:.375rem .5rem}pre{display:block;margin-bottom:var(--spacing);overflow-x:auto}pre>code{background:0 0;display:block;padding:var(--spacing);font-size:14px;line-height:var(--line-height)}code b{color:var(--code-tag-color);font-weight:var(--font-weight)}code i{color:var(--code-property-color);font-style:normal}code u{color:var(--code-value-color);text-decoration:none}code em{color:var(--code-comment-color);font-style:normal}kbd{background-color:var(--code-kbd-background-color);color:var(--code-kbd-color);vertical-align:middle}hr{box-sizing:content-box;height:0;overflow:visible;border:none;border-top:1px solid var(--muted-border-color)}[hidden],template{display:none!important}dialog{display:block;position:absolute;right:0;left:0;width:-moz-fit-content;width:-webkit-fit-content;width:fit-content;height:-moz-fit-content;height:-webkit-fit-content;height:fit-content;margin:auto;padding:1em;border:solid;background-color:#fff;color:#000}dialog:not([open]){display:none}canvas{display:inline-block}details{display:block;margin-bottom:var(--spacing);padding-bottom:calc(var(--spacing) * .5);border-bottom:var(--border-width) solid var(--accordion-border-color)}details summary{color:var(--accordion-close-summary-color);line-height:1rem;list-style-type:none;list-style-type:none;cursor:pointer;transition:color var(--transition)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;float:right;transform:rotate(-90deg);background-image:var(--icon-chevron);background-position:center;background-repeat:no-repeat;background-size:1rem auto;content:"";transition:transform var(--transition)}details summary:focus{outline:0;color:var(--accordion-active-summary-color)}details summary~*{margin-top:calc(var(--spacing) * .5)}details summary~*~*{margin-top:0}details[open]>summary{margin-bottom:calc(var(--spacing) * .25)}details[open]>summary:not(:focus){color:var(--accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary::after{float:left}article{margin:var(--block-spacing-vertical) 0;padding:var(--block-spacing-vertical) var(--block-spacing-horizontal);overflow:hidden;border-radius:var(--border-radius);background:var(--card-background-color);box-shadow:var(--card-box-shadow)}article>footer,article>header{margin-right:calc(var(--block-spacing-horizontal) * -1);margin-left:calc(var(--block-spacing-horizontal) * -1);padding:calc(var(--block-spacing-vertical) * .66) var(--block-spacing-horizontal);background-color:var(--card-sectionning-background-color)}article>header{margin-top:calc(var(--block-spacing-vertical) * -1);margin-bottom:var(--block-spacing-vertical);border-bottom:var(--border-width) solid var(--card-border-color)}article>footer{margin-top:var(--block-spacing-vertical);margin-bottom:calc(var(--block-spacing-vertical) * -1);border-top:var(--border-width) solid var(--card-border-color)}nav,nav ul{display:flex}nav{justify-content:space-between}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--spacing) * -.5)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--spacing) * -.5)}nav li{display:inline-block;margin:0;padding:var(--spacing) calc(var(--spacing) * .5)}nav li>*,nav li>input:not([type=checkbox]):not([type=radio]){margin-bottom:0}nav a{display:block;margin:calc(var(--spacing) * -1) calc(var(--spacing) * -.5);padding:var(--spacing) calc(var(--spacing) * .5);border-radius:var(--border-radius);text-decoration:none}nav a:active,nav a:focus,nav a:hover{text-decoration:none}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--spacing) * .5)}aside li a{margin:calc(var(--spacing) * -.5);padding:calc(var(--spacing) * .5)}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;appearance:none;display:inline-block;width:100%;height:.5rem;margin-bottom:calc(var(--spacing) * .5);overflow:hidden;border:0;border-radius:var(--border-radius);background-color:var(--progress-background-color);color:var(--progress-color)}progress::-webkit-progress-bar{border-radius:var(--border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--progress-color)}progress::-moz-progress-bar{background-color:var(--progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--progress-background-color) linear-gradient(to right,var(--progress-color) 30%,var(--progress-background-color) 30%) top left/150% 150% no-repeat;-webkit-animation:progressIndeterminate 1s linear infinite;animation:progressIndeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@-webkit-keyframes progressIndeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}@keyframes progressIndeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}[aria-busy=true]{cursor:progress}[aria-busy=true]:not(input):not(select):not(textarea)::before{display:inline-block;width:1em;height:1em;border:.1875em solid currentColor;border-radius:1em;border-right-color:transparent;vertical-align:text-bottom;vertical-align:-.125em;-webkit-animation:spinner .75s linear infinite;animation:spinner .75s linear infinite;content:"";opacity:var(--loading-spinner-opacity)}[aria-busy=true]:not(input):not(select):not(textarea):not(:empty)::before{margin-right:calc(var(--spacing) * .5);margin-left:0;-webkit-margin-end:calc(var(--spacing) * .5);margin-inline-end:calc(var(--spacing) * .5);-webkit-margin-start:0;margin-inline-start:0}[aria-busy=true]:not(input):not(select):not(textarea):empty{text-align:center}a[aria-busy=true],button[aria-busy=true],input[type=button][aria-busy=true],input[type=reset][aria-busy=true],input[type=submit][aria-busy=true]{pointer-events:none}@-webkit-keyframes spinner{to{transform:rotate(360deg)}}@keyframes spinner{to{transform:rotate(360deg)}}[data-tooltip]{position:relative}[data-tooltip]:not(a):not(button):not(input){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--border-radius);background:var(--tooltip-background-color);color:var(--tooltip-color);font-size:.875rem;font-style:normal;font-weight:var(--font-weight);text-decoration:none;text-overflow:ellipsis;white-space:nowrap;content:attr(data-tooltip);opacity:0;pointer-events:none}[data-tooltip]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;color:var(--tooltip-background-color);content:""}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1;-webkit-animation-name:slide;animation-name:slide;-webkit-animation-duration:.2s;animation-duration:.2s}[data-tooltip]:focus::after,[data-tooltip]:hover::after{-webkit-animation-name:slideCaret;animation-name:slideCaret}@-webkit-keyframes slide{from{transform:translate(-50%,.75rem);opacity:0}to{transform:translate(-50%,-.25rem);opacity:1}}@keyframes slide{from{transform:translate(-50%,.75rem);opacity:0}to{transform:translate(-50%,-.25rem);opacity:1}}@-webkit-keyframes slideCaret{from{opacity:0}50%{transform:translate(-50%,-.25rem);opacity:0}to{transform:translate(-50%,0);opacity:1}}@keyframes slideCaret{from{opacity:0}50%{transform:translate(-50%,-.25rem);opacity:0}to{transform:translate(-50%,0);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;-webkit-animation-duration:1ms!important;animation-duration:1ms!important;-webkit-animation-delay:-1ms!important;animation-delay:-1ms!important;-webkit-animation-iteration-count:1!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}} -------------------------------------------------------------------------------- /dist/css/storybook.css: -------------------------------------------------------------------------------- 1 | .Storybook { 2 | height: 100vh; 3 | display: grid; 4 | grid-template-areas: 5 | "logo main" 6 | "nav main"; 7 | grid-template-columns: 20rem 1fr; 8 | grid-template-rows: 4rem 1fr; 9 | } 10 | 11 | .Storybook-logo { 12 | grid-area: logo; 13 | display: flex; 14 | align-items: center; 15 | padding-left: 2rem; 16 | text-decoration: none; 17 | background-color: #fafafa; 18 | color: #282828; 19 | border-right: 1px solid rgba(0, 0, 0, 0.08); 20 | border-bottom: 1px solid rgba(0, 0, 0, 0.08); 21 | } 22 | 23 | .Storybook-nav { 24 | grid-area: nav; 25 | overflow-y: auto; 26 | font-size: 0.875rem; 27 | background-color: #fafafa; 28 | border-right: 1px solid rgba(0, 0, 0, 0.08); 29 | } 30 | 31 | .Storybook-nav-list { 32 | list-style: none; 33 | margin: 0; 34 | padding: 0; 35 | } 36 | 37 | .Storybook-nav-section { 38 | margin: 1rem 0; 39 | } 40 | 41 | .Storybook-nav-section-title { 42 | color: #3a3a3a; 43 | text-transform: uppercase; 44 | font-weight: bold; 45 | padding: 0.625rem 2rem; 46 | } 47 | 48 | .Storybook-link { 49 | display: block; 50 | text-decoration: none; 51 | padding: 0.625rem 2rem; 52 | word-wrap: break-word; 53 | color: #282828; 54 | } 55 | 56 | .Storybook-link:hover, 57 | .Storybook-link.is-active { 58 | color: #008cff; 59 | } 60 | 61 | .Storybook-main { 62 | grid-area: main; 63 | padding: 2rem; 64 | overflow: auto; 65 | } 66 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Formless 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /example/Example/Basic.purs: -------------------------------------------------------------------------------- 1 | -- | A basic example that doesn't use app-specific fields or validators, showing 2 | -- | how you would manually wire up a form without any helpers on top of 3 | -- | Formless itself. 4 | module Example.Basic where 5 | 6 | import Prelude 7 | 8 | import Data.Either (Either(..)) 9 | import Data.Maybe (Maybe(..)) 10 | import Effect.Aff (Aff) 11 | import Formless as F 12 | import Halogen as H 13 | import Halogen.HTML as HH 14 | import Halogen.HTML.Events as HE 15 | import Halogen.HTML.Properties as HP 16 | 17 | type Form :: (Type -> Type -> Type -> Type) -> Row Type 18 | type Form f = 19 | ( name :: f String String String 20 | , message :: f String Void String 21 | ) 22 | 23 | type FormContext = F.FormContext (Form F.FieldState) (Form (F.FieldAction Action)) Unit Action 24 | type FormlessAction = F.FormlessAction (Form F.FieldState) 25 | 26 | data Action 27 | = Receive FormContext 28 | | Eval FormlessAction 29 | 30 | form :: forall query. H.Component query Unit { | Form F.FieldOutput } Aff 31 | form = F.formless { liftAction: Eval } mempty $ H.mkComponent 32 | { initialState: identity 33 | , render 34 | , eval: H.mkEval $ H.defaultEval 35 | { receive = Just <<< Receive 36 | , handleAction = handleAction 37 | , handleQuery = handleQuery 38 | } 39 | } 40 | where 41 | handleAction :: Action -> H.HalogenM _ _ _ _ _ Unit 42 | handleAction = case _ of 43 | Receive context -> H.put context 44 | Eval action -> F.eval action 45 | 46 | handleQuery :: forall a. F.FormQuery _ _ _ _ a -> H.HalogenM _ _ _ _ _ (Maybe a) 47 | handleQuery = do 48 | let 49 | validation :: { | Form F.FieldValidation } 50 | validation = 51 | { name: case _ of 52 | "" -> Left "Required" 53 | val -> Right val 54 | , message: Right 55 | } 56 | 57 | F.handleSubmitValidate F.raise F.validate validation 58 | 59 | render :: FormContext -> H.ComponentHTML Action () Aff 60 | render { formActions, fields, actions } = 61 | HH.form 62 | [ HE.onSubmit formActions.handleSubmit ] 63 | [ HH.div_ 64 | [ HH.label_ [ HH.text "Name" ] 65 | , HH.input 66 | [ HP.type_ HP.InputText 67 | , HE.onValueInput actions.name.handleChange 68 | , HE.onBlur actions.name.handleBlur 69 | , case fields.name.result of 70 | Nothing -> HP.placeholder "Jack" 71 | Just (Left _) -> HP.attr (HH.AttrName "aria-invalid") "true" 72 | Just (Right _) -> HP.attr (HH.AttrName "aria-invalid") "false" 73 | ] 74 | , case fields.name.result of 75 | Just (Left err) -> HH.small_ [ HH.text err ] 76 | _ -> HH.text "" 77 | ] 78 | , HH.div_ 79 | [ HH.label_ [ HH.text "Message" ] 80 | , HH.textarea 81 | [ HE.onValueInput actions.message.handleChange 82 | , HE.onBlur actions.message.handleBlur 83 | ] 84 | ] 85 | , HH.button 86 | [ HP.type_ HP.ButtonSubmit ] 87 | [ HH.text "Submit" ] 88 | ] 89 | -------------------------------------------------------------------------------- /example/Example/CheckboxRadio.purs: -------------------------------------------------------------------------------- 1 | module Example.CheckboxRadio where 2 | 3 | import Prelude 4 | 5 | import Data.Either (Either(..)) 6 | import Data.Maybe (Maybe(..)) 7 | import Effect.Aff (Aff) 8 | import Example.Utils.Field as UI 9 | import Example.Utils.Types (Picked(..)) 10 | import Example.Utils.Validation as Validation 11 | import Formless as F 12 | import Halogen as H 13 | import Halogen.HTML as HH 14 | import Halogen.HTML.Events as HE 15 | import Halogen.HTML.Properties as HP 16 | 17 | type Form :: (Type -> Type -> Type -> Type) -> Row Type 18 | type Form f = 19 | ( name :: f String String String 20 | , subscribe :: f Boolean Void Boolean 21 | , picked :: f Picked Void Picked 22 | ) 23 | 24 | type FormContext = F.FormContext (Form F.FieldState) (Form (F.FieldAction Action)) Unit Action 25 | type FormlessAction = F.FormlessAction (Form F.FieldState) 26 | 27 | data Action 28 | = Receive FormContext 29 | | Eval FormlessAction 30 | 31 | form :: forall query. H.Component query Unit { | Form F.FieldOutput } Aff 32 | form = F.formless { liftAction: Eval } initialForm $ H.mkComponent 33 | { initialState: identity 34 | , render 35 | , eval: H.mkEval $ H.defaultEval 36 | { receive = Just <<< Receive 37 | , handleAction = handleAction 38 | , handleQuery = handleQuery 39 | } 40 | } 41 | where 42 | initialForm :: { | Form F.FieldInput } 43 | initialForm = { name: "", subscribe: false, picked: One } 44 | 45 | handleAction :: Action -> H.HalogenM _ _ _ _ _ Unit 46 | handleAction = case _ of 47 | Receive context -> H.put context 48 | Eval action -> F.eval action 49 | 50 | handleQuery :: forall a. F.FormQuery _ _ _ _ a -> H.HalogenM _ _ _ _ _ (Maybe a) 51 | handleQuery = F.handleSubmitValidate F.raise F.validate 52 | { name: Validation.requiredText 53 | , subscribe: Right 54 | , picked: Right 55 | } 56 | 57 | render :: FormContext -> H.ComponentHTML Action () Aff 58 | render { formActions, fields, actions } = 59 | HH.form 60 | [ HE.onSubmit formActions.handleSubmit ] 61 | [ UI.textInput 62 | { label: "Name" 63 | , state: fields.name 64 | , action: actions.name 65 | } 66 | [ HP.placeholder "Jack" ] 67 | , UI.checkbox_ 68 | { label: "Subscribe" 69 | , state: fields.subscribe 70 | , action: actions.subscribe 71 | } 72 | , UI.radioGroup 73 | { label: "Pick One" 74 | , options: 75 | [ { option: One, render: "One", props: [] } 76 | , { option: Two, render: "Two", props: [] } 77 | , { option: Three, render: "Three", props: [] } 78 | ] 79 | , state: fields.picked 80 | , action: actions.picked 81 | } 82 | , HH.br_ 83 | , HH.button 84 | [ HP.type_ HP.ButtonSubmit ] 85 | [ HH.text "Submit" ] 86 | ] 87 | -------------------------------------------------------------------------------- /example/Example/DependentFields.purs: -------------------------------------------------------------------------------- 1 | module Example.DependentFields where 2 | 3 | import Prelude 4 | 5 | import Control.Monad.Except (runExceptT) 6 | import Control.Monad.Except as ExceptT 7 | import Data.Either (Either(..), isRight) 8 | import Data.Maybe (Maybe(..), isNothing) 9 | import Data.String (codePointFromChar) 10 | import Data.String as String 11 | import Effect.Aff (Aff) 12 | import Effect.Aff as Aff 13 | import Example.Utils.Field as UI 14 | import Example.Utils.Types (Email, Username) 15 | import Example.Utils.Validation as Validation 16 | import Formless as F 17 | import Halogen as H 18 | import Halogen.HTML as HH 19 | import Halogen.HTML.Events as HE 20 | import Halogen.HTML.Properties as HP 21 | 22 | type Output = 23 | { email :: Email 24 | , username :: Username 25 | , password :: String 26 | } 27 | 28 | type Form :: (Type -> Type -> Type -> Type) -> Row Type 29 | type Form f = 30 | ( email :: f String String Email 31 | , username :: f String String Username 32 | , password1 :: f String String String 33 | , password2 :: f String String String 34 | ) 35 | 36 | type FormContext = F.FormContext (Form F.FieldState) (Form (F.FieldAction Action)) Unit Action 37 | type FormlessAction = F.FormlessAction (Form F.FieldState) 38 | 39 | data Action 40 | = Receive FormContext 41 | | Eval FormlessAction 42 | 43 | form :: forall query. H.Component query Unit Output Aff 44 | form = F.formless { liftAction: Eval, validateOnModify: true } mempty $ H.mkComponent 45 | { initialState: identity 46 | , render 47 | , eval: H.mkEval $ H.defaultEval 48 | { receive = Just <<< Receive 49 | , handleAction = handleAction 50 | , handleQuery = handleQuery 51 | } 52 | } 53 | where 54 | handleAction :: Action -> H.HalogenM _ _ _ _ _ Unit 55 | handleAction = case _ of 56 | Receive context -> H.put context 57 | Eval action -> F.eval action 58 | 59 | handleQuery :: forall a. F.FormQuery _ _ _ _ a -> H.HalogenM _ _ _ _ _ (Maybe a) 60 | handleQuery = do 61 | let 62 | validatePasswords 63 | :: String 64 | -> { value :: String, result :: Maybe (Either String String), validate :: Action } 65 | -> H.HalogenM _ _ _ _ _ (Either String String) 66 | validatePasswords input otherPassword = runExceptT do 67 | let 68 | validateEq a b 69 | | a == b = Right a 70 | | otherwise = Left "Passwords must match." 71 | 72 | password <- ExceptT.except $ Validation.longerThan 3 =<< Validation.requiredText input 73 | _ <- ExceptT.except $ validateEq password otherPassword.value 74 | case otherPassword.result of 75 | Just (Left _) -> do 76 | -- If this password is valid and the passwords match, but the other 77 | -- password field has an error, then it has a stale validation error 78 | -- for not matching this field. We should therefore trigger 79 | -- validation on that field. 80 | ExceptT.lift $ void $ H.fork do 81 | H.liftAff $ Aff.delay $ Aff.Milliseconds 10.0 82 | handleAction otherPassword.validate 83 | _ -> pure unit 84 | pure password 85 | 86 | -- This validation all runs in `HalogenM`, so you have full access to your 87 | -- component (including the form itself). Below, we demonstrate some ways 88 | -- you can use this information. 89 | validation :: { | Form (F.FieldValidationM (H.HalogenM _ _ _ _ _)) } 90 | validation = 91 | { email: \input -> do 92 | let validated = Validation.email input 93 | -- If the email address is valid and the username field hasn't been 94 | -- touched yet, then we'll pre-fill the contents of the username 95 | -- field on behalf of the user. 96 | usernameResult <- H.gets _.fields.username.result 97 | when (isRight validated && isNothing usernameResult) do 98 | let start = String.takeWhile (_ /= codePointFromChar '@') input 99 | -- Since we've set `validateOnModify` to be true in our form 100 | -- config, modifying this field manually will trigger validation 101 | -- automatically. 102 | modifyUsername <- H.gets _.actions.username.modify 103 | handleAction $ modifyUsername _ { value = start } 104 | pure validated 105 | 106 | , username: 107 | pure <<< Validation.username 108 | 109 | , password1: \input -> do 110 | { value, result } <- H.gets _.fields.password2 111 | { validate } <- H.gets _.actions.password2 112 | validatePasswords input { value, result, validate } 113 | 114 | , password2: \input -> do 115 | { value, result } <- H.gets _.fields.password1 116 | { validate } <- H.gets _.actions.password1 117 | validatePasswords input { value, result, validate } 118 | } 119 | 120 | -- We'd like to modify the output of our form to consolidate the duplicate 121 | -- password fields. 122 | toOutput :: { | Form F.FieldOutput } -> Output 123 | toOutput { email, username, password1 } = { email, username, password: password1 } 124 | 125 | F.handleSubmitValidateM (F.raise <<< toOutput) F.validateM validation 126 | 127 | render :: FormContext -> H.ComponentHTML Action () Aff 128 | render { formActions, fields, actions } = 129 | HH.form 130 | [ HE.onSubmit formActions.handleSubmit ] 131 | [ UI.textInput 132 | { label: "Email" 133 | , state: fields.email 134 | , action: actions.email 135 | } 136 | [ HP.placeholder "jack@kerouac.com" ] 137 | , UI.textInput 138 | { label: "Username" 139 | , state: fields.username 140 | , action: actions.username 141 | } 142 | [ HP.placeholder "jk" ] 143 | , UI.textInput 144 | { label: "Password" 145 | , state: fields.password1 146 | , action: actions.password1 147 | } 148 | [ HP.type_ HP.InputPassword ] 149 | , UI.textInput 150 | { label: "Password (Confirm)" 151 | , state: fields.password2 152 | , action: actions.password2 153 | } 154 | [ HP.type_ HP.InputPassword ] 155 | , HH.button 156 | [ HP.type_ HP.ButtonSubmit ] 157 | [ HH.text "Submit" ] 158 | ] 159 | -------------------------------------------------------------------------------- /example/Example/FileUpload.purs: -------------------------------------------------------------------------------- 1 | module Example.FileUpload where 2 | 3 | import Prelude 4 | 5 | import Data.Array (elem) 6 | import Data.Array as Array 7 | import Data.Either (Either(..)) 8 | import Data.Maybe (Maybe(..)) 9 | import Data.MediaType.Common as MediaType 10 | import Data.Newtype (unwrap) 11 | import Effect.Aff (Aff) 12 | import Example.Utils.Field as UI 13 | import Example.Utils.Validation as Validation 14 | import Formless as F 15 | import Halogen as H 16 | import Halogen.HTML as HH 17 | import Halogen.HTML.Events as HE 18 | import Halogen.HTML.Properties as HP 19 | import Web.File.File (File) 20 | import Web.File.File as File 21 | 22 | type Form :: (Type -> Type -> Type -> Type) -> Row Type 23 | type Form f = 24 | ( name :: f String String String 25 | , photo :: f (Array File) String { name :: String, size :: Number } 26 | ) 27 | 28 | type FormContext = F.FormContext (Form F.FieldState) (Form (F.FieldAction Action)) Unit Action 29 | type FormlessAction = F.FormlessAction (Form F.FieldState) 30 | 31 | data Action 32 | = Receive FormContext 33 | | Eval FormlessAction 34 | 35 | form :: forall query. H.Component query Unit { | Form F.FieldOutput } Aff 36 | form = F.formless { liftAction: Eval } mempty $ H.mkComponent 37 | { initialState: identity 38 | , render 39 | , eval: H.mkEval $ H.defaultEval 40 | { receive = Just <<< Receive 41 | , handleAction = handleAction 42 | , handleQuery = handleQuery 43 | } 44 | } 45 | where 46 | handleAction :: Action -> H.HalogenM _ _ _ _ _ Unit 47 | handleAction = case _ of 48 | Receive context -> H.put context 49 | Eval action -> F.eval action 50 | 51 | handleQuery :: forall a. F.FormQuery _ _ _ _ a -> H.HalogenM _ _ _ _ _ (Maybe a) 52 | handleQuery = F.handleSubmitValidate F.raise F.validate 53 | { name: Validation.requiredText 54 | , photo: validateFileSize <=< validateFileType <=< validateFileCount 55 | } 56 | 57 | render :: FormContext -> H.ComponentHTML Action () Aff 58 | render { formActions, fields, actions } = 59 | HH.form 60 | [ HE.onSubmit formActions.handleSubmit ] 61 | [ UI.textInput 62 | { label: "Name" 63 | , state: fields.name 64 | , action: actions.name 65 | } 66 | [ HP.placeholder "Jack" ] 67 | , UI.fileUpload_ 68 | { label: "Upload Photo" 69 | , state: fields.photo 70 | , action: actions.photo 71 | , onValid: \{ size } -> HH.small_ [ HH.text $ "Your photo size: " <> show size ] 72 | } 73 | , HH.br_ 74 | , HH.button 75 | [ HP.type_ HP.ButtonSubmit ] 76 | [ HH.text "Submit" ] 77 | ] 78 | 79 | validateFileCount :: Array File -> Either String File 80 | validateFileCount files = case Array.uncons files of 81 | Just { head, tail: [] } -> Right head 82 | Just _ -> Left "Only one photo can be uploaded." 83 | Nothing -> Left "Required." 84 | 85 | validateFileType :: File -> Either String File 86 | validateFileType file = case File.type_ file of 87 | Nothing -> Left "Unrecognized file type. Accepted types: png, jpeg, gif." 88 | Just ty | ty `elem` [ MediaType.imagePNG, MediaType.imageJPEG, MediaType.imageGIF ] -> Right file 89 | Just ty -> Left ("Unsupported file type: " <> unwrap ty <> ". Accepted types: png, jpeg, gif.") 90 | 91 | validateFileSize :: File -> Either String { name :: String, size :: Number } 92 | validateFileSize file = case File.size file of 93 | size 94 | | size > 99_999.0 -> Left "Photos must be smaller than 100kb." 95 | | size < 1000.0 -> Left "Photos cannot be smaller than 1kb." 96 | | otherwise -> Right { name: File.name file, size } 97 | -------------------------------------------------------------------------------- /example/Example/LocalStorage.purs: -------------------------------------------------------------------------------- 1 | module Example.LocalStorage where 2 | 3 | import Prelude 4 | 5 | import Data.Argonaut as Json 6 | import Data.Either (Either(..), note) 7 | import Data.Maybe (Maybe(..)) 8 | import Effect.Aff (Aff) 9 | import Effect.Class.Console as Console 10 | import Example.Utils.Field as UI 11 | import Example.Utils.Validation as Validation 12 | import Formless as F 13 | import Halogen as H 14 | import Halogen.HTML as HH 15 | import Halogen.HTML.Events as HE 16 | import Halogen.HTML.Properties as HP 17 | import Web.HTML as HTML 18 | import Web.HTML.Window as Window 19 | import Web.Storage.Storage as Storage 20 | 21 | type StringField :: (Type -> Type -> Type -> Type) -> Type 22 | type StringField f = f String String String 23 | 24 | type Form :: (Type -> Type -> Type -> Type) -> Row Type 25 | type Form f = 26 | ( name :: StringField f 27 | , nickname :: StringField f 28 | , city :: StringField f 29 | , state :: StringField f 30 | ) 31 | 32 | type FormContext = F.FormContext (Form F.FieldState) (Form (F.FieldAction Action)) Unit Action 33 | type FormAction = F.FormlessAction (Form F.FieldState) 34 | 35 | data Action 36 | = Initialize 37 | | Receive FormContext 38 | | Eval FormAction 39 | 40 | form :: forall query. H.Component query Unit { | Form F.FieldOutput } Aff 41 | form = F.formless { liftAction: Eval } mempty $ H.mkComponent 42 | { initialState: identity 43 | , render 44 | , eval: H.mkEval $ H.defaultEval 45 | { receive = Just <<< Receive 46 | , initialize = Just Initialize 47 | , handleAction = handleAction 48 | , handleQuery = handleQuery 49 | } 50 | } 51 | where 52 | key :: String 53 | key = "local-storage-form" 54 | 55 | handleAction :: Action -> H.HalogenM _ _ _ _ _ Unit 56 | handleAction = case _ of 57 | Initialize -> do 58 | storedState <- H.liftEffect $ Storage.getItem key =<< Window.localStorage =<< HTML.window 59 | case Json.decodeJson =<< Json.parseJson =<< note (Json.TypeMismatch "No data") storedState of 60 | Left err -> 61 | Console.log $ Json.printJsonDecodeError err 62 | Right fields -> do 63 | setFields <- H.gets _.formActions.setFields 64 | handleAction $ setFields fields 65 | 66 | Receive context -> do 67 | let fieldsJson = Json.stringify $ Json.encodeJson context.fields 68 | H.liftEffect $ Storage.setItem key fieldsJson =<< Window.localStorage =<< HTML.window 69 | H.put context 70 | 71 | Eval action -> 72 | F.eval action 73 | 74 | handleQuery :: forall a. F.FormQuery _ _ _ _ a -> H.HalogenM _ _ _ _ _ (Maybe a) 75 | handleQuery = F.handleSubmitValidate F.raise F.validate 76 | { name: Validation.requiredText 77 | , nickname: Right 78 | , city: Validation.requiredText 79 | , state: Validation.requiredText 80 | } 81 | 82 | render :: FormContext -> H.ComponentHTML Action () Aff 83 | render { formActions, fields, actions } = 84 | HH.form 85 | [ HE.onSubmit formActions.handleSubmit ] 86 | [ UI.textInput 87 | { label: "Name" 88 | , state: fields.name 89 | , action: actions.name 90 | } 91 | [ HP.placeholder "Jack" ] 92 | , UI.textInput 93 | { label: "Nickname" 94 | , state: fields.nickname 95 | , action: actions.nickname 96 | } 97 | [] 98 | , UI.textInput 99 | { label: "City" 100 | , state: fields.city 101 | , action: actions.city 102 | } 103 | [ HP.placeholder "Los Angeles" ] 104 | , UI.textInput 105 | { label: "State" 106 | , state: fields.state 107 | , action: actions.state 108 | } 109 | [ HP.placeholder "California" ] 110 | , HH.br_ 111 | , HH.button 112 | [ HP.type_ HP.ButtonSubmit ] 113 | [ HH.text "Submit" ] 114 | ] 115 | -------------------------------------------------------------------------------- /example/Example/Utils/Field.purs: -------------------------------------------------------------------------------- 1 | -- | This module demonstrates how to write custom form fields for your 2 | -- | application on top of the helpers provided by Formless. Most applications 3 | -- | will have a module like this which defines common, reusable form inputs. 4 | module Example.Utils.Field where 5 | 6 | import Prelude 7 | 8 | import DOM.HTML.Indexed (HTMLinput, HTMLtextarea) 9 | import Data.Either (Either(..)) 10 | import Data.Maybe (Maybe(..)) 11 | import Formless (FieldAction, FieldState) 12 | import Halogen.HTML as H 13 | import Halogen.HTML as HH 14 | import Halogen.HTML.Events as HE 15 | import Halogen.HTML.Properties as HP 16 | import Web.File.File (File) 17 | 18 | type TextInput action output = 19 | { label :: String 20 | , state :: FieldState String String output 21 | , action :: FieldAction action String String output 22 | } 23 | 24 | textInput 25 | :: forall output action slots m 26 | . TextInput action output 27 | -> Array (HP.IProp HTMLinput action) 28 | -> H.ComponentHTML action slots m 29 | textInput { label, state, action } = 30 | withLabel { label, state } <<< HH.input <<< append 31 | [ HP.value state.value 32 | , case state.result of 33 | Nothing -> HP.attr (HH.AttrName "aria-touched") "false" 34 | Just (Left _) -> HP.attr (HH.AttrName "aria-invalid") "true" 35 | Just (Right _) -> HP.attr (HH.AttrName "aria-invalid") "false" 36 | , HE.onValueInput action.handleChange 37 | , HE.onBlur action.handleBlur 38 | ] 39 | 40 | textInput_ 41 | :: forall output action slots m 42 | . TextInput action output 43 | -> H.ComponentHTML action slots m 44 | textInput_ = flip textInput [] 45 | 46 | type Textarea action output = 47 | { label :: String 48 | , state :: FieldState String String output 49 | , action :: FieldAction action String String output 50 | } 51 | 52 | textarea 53 | :: forall output action slots m 54 | . Textarea action output 55 | -> Array (HP.IProp HTMLtextarea action) 56 | -> H.ComponentHTML action slots m 57 | textarea { label, state, action } = 58 | withLabel { label, state } <<< HH.textarea <<< append 59 | [ HP.value state.value 60 | , HE.onValueInput action.handleChange 61 | , HE.onBlur action.handleBlur 62 | ] 63 | 64 | textarea_ 65 | :: forall output action slots m 66 | . Textarea action output 67 | -> H.ComponentHTML action slots m 68 | textarea_ = flip textarea [] 69 | 70 | type Checkbox error action = 71 | { label :: String 72 | , state :: FieldState Boolean error Boolean 73 | , action :: FieldAction action Boolean error Boolean 74 | } 75 | 76 | checkbox 77 | :: forall error action slots m 78 | . Checkbox error action 79 | -> Array (HP.IProp HTMLinput action) 80 | -> H.ComponentHTML action slots m 81 | checkbox { label, state, action } props = 82 | HH.fieldset_ 83 | [ HH.label_ 84 | [ HH.input $ flip append props 85 | [ HP.type_ HP.InputCheckbox 86 | , HP.checked state.value 87 | , HE.onChecked action.handleChange 88 | , HE.onBlur action.handleBlur 89 | ] 90 | , HH.text label 91 | ] 92 | ] 93 | 94 | checkbox_ 95 | :: forall error action slots m 96 | . Checkbox error action 97 | -> H.ComponentHTML action slots m 98 | checkbox_ = flip checkbox [] 99 | 100 | type RadioGroup action input output = 101 | { label :: String 102 | , state :: FieldState input Void output 103 | , action :: FieldAction action input Void output 104 | , options :: 105 | Array 106 | { option :: input 107 | , render :: String 108 | , props :: Array (HP.IProp HTMLinput action) 109 | } 110 | } 111 | 112 | radioGroup 113 | :: forall input output action slots m 114 | . Eq input 115 | => RadioGroup action input output 116 | -> H.ComponentHTML action slots m 117 | radioGroup { label, state, action, options } = 118 | HH.div_ 119 | [ HH.label_ [ HH.text label ] 120 | , HH.fieldset_ $ options <#> \{ option, render, props } -> 121 | HH.label_ 122 | [ HH.input $ flip append props 123 | [ HP.type_ HP.InputRadio 124 | , HP.name action.key 125 | , HP.checked (state.value == option) 126 | , HE.onChange (\_ -> action.handleChange option) 127 | , HE.onBlur action.handleBlur 128 | ] 129 | , HH.text render 130 | ] 131 | ] 132 | 133 | type FileUpload action slots m output = 134 | { label :: String 135 | , state :: FieldState (Array File) String output 136 | , action :: FieldAction action (Array File) String output 137 | , onValid :: output -> H.ComponentHTML action slots m 138 | } 139 | 140 | fileUpload 141 | :: forall action slots m output 142 | . FileUpload action slots m output 143 | -> Array (HP.IProp HTMLinput action) 144 | -> H.ComponentHTML action slots m 145 | fileUpload { label, state, action, onValid } props = 146 | HH.div_ 147 | [ HH.label 148 | [ HP.for action.key ] 149 | [ HH.text label 150 | , HH.input $ flip append props 151 | [ HP.name action.key 152 | , HP.type_ HP.InputFile 153 | , HE.onFileUpload action.handleChange 154 | , HE.onBlur action.handleBlur 155 | ] 156 | ] 157 | , case state.result of 158 | Just (Left error) -> HH.small_ [ HH.text error ] 159 | Just (Right output) -> HH.small_ [ onValid output ] 160 | _ -> HH.text "" 161 | ] 162 | 163 | fileUpload_ 164 | :: forall action slots m output 165 | . FileUpload action slots m output 166 | -> H.ComponentHTML action slots m 167 | fileUpload_ = flip fileUpload [] 168 | 169 | type Labelled input output = 170 | { label :: String 171 | , state :: FieldState input String output 172 | } 173 | 174 | -- Attach a label and error text to a form input 175 | withLabel 176 | :: forall input output action slots m 177 | . Labelled input output 178 | -> H.ComponentHTML action slots m 179 | -> H.ComponentHTML action slots m 180 | withLabel { label, state } html = 181 | HH.div_ 182 | [ HH.label_ [ HH.text label ] 183 | , html 184 | , case state.result of 185 | Just (Left error) -> HH.small_ [ HH.text error ] 186 | _ -> HH.text "" 187 | ] 188 | -------------------------------------------------------------------------------- /example/Example/Utils/Types.purs: -------------------------------------------------------------------------------- 1 | module Example.Utils.Types where 2 | 3 | import Prelude 4 | 5 | import Data.Newtype (class Newtype) 6 | 7 | data Picked = One | Two | Three 8 | 9 | derive instance Eq Picked 10 | 11 | instance Show Picked where 12 | show = case _ of 13 | One -> "One" 14 | Two -> "Two" 15 | Three -> "Three" 16 | 17 | newtype Username = Username String 18 | 19 | derive instance Newtype Username _ 20 | 21 | instance Show Username where 22 | show (Username username) = "(Username " <> username <> ")" 23 | 24 | newtype Email = Email String 25 | 26 | derive instance Newtype Email _ 27 | 28 | instance Show Email where 29 | show (Email email) = "(Email " <> email <> ")" 30 | -------------------------------------------------------------------------------- /example/Example/Utils/Validation.purs: -------------------------------------------------------------------------------- 1 | -- | This module demonstrates how to write simple validation functions for your 2 | -- | forms. You can write validation functions of any kind so long as they 3 | -- | ultimately result in an `Either` value. 4 | module Example.Utils.Validation where 5 | 6 | import Prelude 7 | 8 | import Data.Either (Either(..)) 9 | import Data.String (Pattern(..)) 10 | import Data.String as String 11 | import Example.Utils.Types (Email(..), Username(..)) 12 | 13 | requiredText :: String -> Either String String 14 | requiredText input 15 | | input == "" = Left "Required." 16 | | otherwise = Right input 17 | 18 | shorterThan :: Int -> String -> Either String String 19 | shorterThan limit str 20 | | String.length str >= limit = Left $ "Must be shorter than " <> show limit <> " characters." 21 | | otherwise = Right str 22 | 23 | longerThan :: Int -> String -> Either String String 24 | longerThan limit str 25 | | String.length str <= limit = Left $ "Must be longer than " <> show limit <> " characters." 26 | | otherwise = Right str 27 | 28 | email :: String -> Either String Email 29 | email str 30 | | not (String.contains (Pattern "@") str) = Left "Must contain the @ character." 31 | | not (String.contains (Pattern ".") str) = Left "Must end in a top-level domain like '.com'." 32 | | String.length str <= 5 = Left "Not a valid email address." 33 | | otherwise = Right $ Email str 34 | 35 | username :: String -> Either String Username 36 | username = map Username <<< (longerThan 5 <=< requiredText) 37 | -------------------------------------------------------------------------------- /example/Main.purs: -------------------------------------------------------------------------------- 1 | module Example.Main where 2 | 3 | import Prelude 4 | 5 | import Data.Foldable (for_) 6 | import Data.Maybe (Maybe(..)) 7 | import Data.Tuple (Tuple(..)) 8 | import Effect (Effect) 9 | import Effect.Aff (Aff, launchAff_) 10 | import Example.Basic as Basic 11 | import Example.CheckboxRadio as CheckboxRadio 12 | import Example.DependentFields as DependentFields 13 | import Example.FileUpload as FileUpload 14 | import Example.LocalStorage as LocalStorage 15 | import Foreign.Object as Object 16 | import Halogen as H 17 | import Halogen.Aff as HA 18 | import Halogen.HTML as HH 19 | import Halogen.Storybook (Stories) 20 | import Halogen.Storybook as Storybook 21 | import Type.Proxy (Proxy(..)) 22 | import Web.DOM.ParentNode (QuerySelector(..)) 23 | 24 | main :: Effect Unit 25 | main = launchAff_ do 26 | _ <- HA.awaitLoad 27 | mbApp <- HA.selectElement (QuerySelector ".app") 28 | for_ mbApp \app -> 29 | Storybook.runStorybook { stories, logo: Just $ HH.text "Formless" } app 30 | 31 | stories :: Stories Aff 32 | stories = Object.fromFoldable 33 | [ home 34 | , basic 35 | , checkboxRadio 36 | , fileUpload 37 | , dependentFields 38 | , localStorage 39 | ] 40 | where 41 | home = do 42 | let 43 | title = HH.h1_ [ HH.text "Formless" ] 44 | description = HH.p_ [ HH.text "A simple library for writing forms in Halogen" ] 45 | render = HH.article_ [ title, description ] 46 | component = H.mkComponent { initialState: identity, render: \_ -> render, eval: H.mkEval H.defaultEval } 47 | 48 | Tuple "" $ Storybook.proxy component 49 | 50 | basic = do 51 | let 52 | title = "Basic" 53 | description = "A simple form that implements all fields manually, without app-specific helpers. Useful to see exactly how Formless should be used when implementing your own helper functions for your application." 54 | component = mkExample title description Basic.form 55 | 56 | Tuple ("1. " <> title) $ Storybook.proxy component 57 | 58 | checkboxRadio = do 59 | let 60 | title = "Checkbox & Radio" 61 | description = "A form demonstrating how to use common form controls like checkboxes and radio groups. Useful to see how to implement your own form field helpers for your application." 62 | component = mkExample title description CheckboxRadio.form 63 | 64 | Tuple ("2. " <> title) $ Storybook.proxy component 65 | 66 | fileUpload = do 67 | let 68 | title = "File Upload" 69 | description = "A form with a file upload button and several validation functions. Useful to see how to implement more complex form fields and validation." 70 | component = mkExample title description FileUpload.form 71 | 72 | Tuple ("3. " <> title) $ Storybook.proxy component 73 | 74 | dependentFields = do 75 | let 76 | title = "Dependent Fields" 77 | description = "A form with fields that can set other field values and fields that mutually depend on each other for validation. Useful to see how to implement validation that accesses the form state and performs effects, as well as how to imperatively modify field states." 78 | component = mkExample title description DependentFields.form 79 | 80 | Tuple ("4. " <> title) $ Storybook.proxy component 81 | 82 | localStorage = do 83 | let 84 | title = "Local Storage" 85 | description = "A form that persists its state to local storage. Click the submit button and then refresh the page! Useful to see how to imperatively set the form state." 86 | component = mkExample title description LocalStorage.form 87 | 88 | Tuple ("5. " <> title) $ Storybook.proxy component 89 | 90 | mkExample 91 | :: forall q i o result 92 | . Show result 93 | => String 94 | -> String 95 | -> H.Component q Unit result Aff 96 | -> H.Component q i o Aff 97 | mkExample title description formComponent = H.mkComponent 98 | { initialState: \_ -> { result: Nothing } 99 | , render 100 | , eval: H.mkEval $ H.defaultEval { handleAction = handleAction } 101 | } 102 | where 103 | handleAction result = 104 | H.modify_ _ { result = Just result } 105 | 106 | render state = 107 | HH.article_ 108 | [ HH.h1_ [ HH.text title ] 109 | , HH.p_ [ HH.text description ] 110 | , HH.slot (Proxy :: Proxy "inner") unit formComponent unit identity 111 | , case state.result of 112 | Nothing -> HH.text "" 113 | Just result -> HH.code_ [ HH.text $ show result ] 114 | ] 115 | -------------------------------------------------------------------------------- /example/example.dhall: -------------------------------------------------------------------------------- 1 | { name = "formless-examples" 2 | , dependencies = 3 | [ "aff" 4 | , "argonaut" 5 | , "arrays" 6 | , "console" 7 | , "convertable-options" 8 | , "dom-indexed" 9 | , "effect" 10 | , "either" 11 | , "foldable-traversable" 12 | , "foreign-object" 13 | , "halogen" 14 | , "halogen-storybook" 15 | , "heterogeneous" 16 | , "maybe" 17 | , "media-types" 18 | , "newtype" 19 | , "prelude" 20 | , "record" 21 | , "safe-coerce" 22 | , "strings" 23 | , "transformers" 24 | , "tuples" 25 | , "type-equality" 26 | , "unsafe-coerce" 27 | , "unsafe-reference" 28 | , "variant" 29 | , "web-dom" 30 | , "web-events" 31 | , "web-file" 32 | , "web-html" 33 | , "web-storage" 34 | , "web-uievents" 35 | ] 36 | , packages = ../packages.dhall 37 | , sources = [ "src/**/*.purs", "example/**/*.purs" ] 38 | } 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "build": "spago build", 4 | "build:examples": "spago --config example/example.dhall build", 5 | "storybook": "spago --config example/example.dhall bundle-app --main Example.Main --to dist/app.js" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages.dhall: -------------------------------------------------------------------------------- 1 | let upstream = 2 | https://github.com/purescript/package-sets/releases/download/psc-0.15.0-20220527/packages.dhall 3 | sha256:15dd8041480502850e4043ea2977ed22d6ab3fc24d565211acde6f8c5152a799 4 | 5 | in upstream 6 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | let 2 | pkgs = import (builtins.fetchTarball { 3 | url = "https://github.com/NixOS/nixpkgs/archive/21.11.tar.gz"; 4 | }) {}; 5 | 6 | # To update to a newer version of easy-purescript-nix, run: 7 | # nix-prefetch-git https://github.com/justinwoo/easy-purescript-nix 8 | # 9 | # Then, copy the resulting rev and sha256 here. 10 | pursPkgs = import (pkgs.fetchFromGitHub { 11 | owner = "justinwoo"; 12 | repo = "easy-purescript-nix"; 13 | rev = "0ad5775c1e80cdd952527db2da969982e39ff592"; 14 | sha256 = "0x53ads5v8zqsk4r1mfpzf5913byifdpv5shnvxpgw634ifyj1kg"; 15 | }) {inherit pkgs;}; 16 | in 17 | pkgs.stdenv.mkDerivation { 18 | name = "halogen-formless"; 19 | buildInputs = with pursPkgs; [ 20 | purs 21 | spago 22 | psa 23 | purs-tidy 24 | purescript-language-server 25 | pulp 26 | 27 | pkgs.nodejs-16_x 28 | pkgs.nodePackages.bower 29 | pkgs.esbuild 30 | ]; 31 | } 32 | -------------------------------------------------------------------------------- /spago.dhall: -------------------------------------------------------------------------------- 1 | { name = "halogen-formless" 2 | , license = "MIT" 3 | , repository = "https://github.com/thomashoneyman/purescript-halogen-formless" 4 | , dependencies = 5 | [ "convertable-options" 6 | , "effect" 7 | , "either" 8 | , "foldable-traversable" 9 | , "foreign-object" 10 | , "halogen" 11 | , "heterogeneous" 12 | , "maybe" 13 | , "prelude" 14 | , "record" 15 | , "safe-coerce" 16 | , "type-equality" 17 | , "unsafe-coerce" 18 | , "unsafe-reference" 19 | , "variant" 20 | , "web-events" 21 | , "web-uievents" 22 | ] 23 | , packages = ./packages.dhall 24 | , sources = [ "src/**/*.purs" ] 25 | } 26 | -------------------------------------------------------------------------------- /src/Formless.purs: -------------------------------------------------------------------------------- 1 | module Formless 2 | ( formless 3 | , FieldInput 4 | , FieldState 5 | , FieldAction 6 | , FieldResult 7 | , FieldOutput 8 | , FieldValidation 9 | , FieldValidationM 10 | , validate 11 | , validateM 12 | , FormContext 13 | , FormConfig 14 | , OptionalFormConfig 15 | , FormQuery(..) 16 | , FormState 17 | , FormAction 18 | , FormOutput(..) 19 | , eval 20 | , raise 21 | , FormlessAction -- don't export constructors 22 | , handleSubmitValidate 23 | , handleSubmitValidateM 24 | -- The below exports are classes and functions that must be exported for type 25 | -- inference for Formless to work, but they shouldn't be needed explicitly in 26 | -- user code. 27 | , class MkConfig 28 | , mkConfig 29 | , MkFieldState 30 | , class MkFieldStates 31 | , mkFieldStates 32 | , MkFieldAction 33 | , class MkFieldActions 34 | , mkFieldActions 35 | , MkFieldResult 36 | , class MkFieldResults 37 | , mkFieldResults 38 | , MkFieldOutput 39 | , class MkFieldOutputs 40 | , mkFieldOutputs 41 | ) where 42 | 43 | import Prelude 44 | 45 | import ConvertableOptions (class Defaults, defaults) 46 | import Data.Either (Either(..), hush) 47 | import Data.Foldable (for_) 48 | import Data.Maybe (Maybe(..)) 49 | import Data.Symbol (class IsSymbol, reflectSymbol) 50 | import Data.Variant (class VariantMapCases, Variant) 51 | import Data.Variant as Variant 52 | import Data.Variant.Internal (class VariantTraverseCases, VariantRep(..)) 53 | import Effect.Class (class MonadEffect) 54 | import Foreign.Object (Object) 55 | import Foreign.Object as Object 56 | import Foreign.Object.Unsafe as Object.Unsafe 57 | import Halogen as H 58 | import Halogen.HTML as HH 59 | import Heterogeneous.Folding (class FoldingWithIndex, class HFoldlWithIndex, hfoldlWithIndex) 60 | import Heterogeneous.Mapping (class HMap, class HMapWithIndex, class Mapping, class MappingWithIndex, hmap, hmapWithIndex) 61 | import Prim.Row as Row 62 | import Prim.RowList as RL 63 | import Record as Record 64 | import Record.Builder (Builder) 65 | import Record.Builder as Builder 66 | import Safe.Coerce (coerce) 67 | import Type.Equality (class TypeEquals) 68 | import Type.Equality as Type.Equality 69 | import Type.Proxy (Proxy(..)) 70 | import Unsafe.Coerce (unsafeCoerce) 71 | import Unsafe.Reference (unsafeRefEq) 72 | import Web.Event.Event (Event) 73 | import Web.Event.Event as Event 74 | import Web.UIEvent.FocusEvent (FocusEvent) 75 | 76 | -- | A type synonym which picks the `input` type from a form field. 77 | type FieldInput :: Type -> Type -> Type -> Type 78 | type FieldInput input error output = input 79 | 80 | -- | A type synonym which represents the current state of a form field. 81 | type FieldState :: Type -> Type -> Type -> Type 82 | type FieldState input error output = 83 | { initialValue :: input 84 | , value :: input 85 | , result :: Maybe (Either error output) 86 | } 87 | 88 | -- | A type synonym which represents the available actions that can be called 89 | -- | on a form field. 90 | type FieldAction :: Type -> Type -> Type -> Type -> Type 91 | type FieldAction action input error output = 92 | { key :: String 93 | , modify :: (FieldState input error output -> FieldState input error output) -> action 94 | , reset :: action 95 | , validate :: action 96 | , handleChange :: input -> action 97 | , handleBlur :: FocusEvent -> action 98 | } 99 | 100 | -- | A type synonm which represents a pure validation function for a form field. 101 | type FieldValidation :: Type -> Type -> Type -> Type 102 | type FieldValidation input error output = input -> Either error output 103 | 104 | -- | Validate a variant of form field inputs by providing a record of validation 105 | -- | functions, one per possible case in the variant. Used for pure validation. 106 | validate 107 | :: forall validators xs r1 r2 r3 inputs results 108 | . RL.RowToList validators xs 109 | => VariantMapCases xs r1 r2 110 | => Row.Union r1 r3 inputs 111 | => Row.Union r2 r3 results 112 | => Variant inputs 113 | -> Record validators 114 | -> Variant results 115 | validate = flip Variant.over 116 | 117 | -- | A type synonm which represents an effectful validation function for a form 118 | -- | field. 119 | type FieldValidationM :: (Type -> Type) -> Type -> Type -> Type -> Type 120 | type FieldValidationM m input error output = input -> m (Either error output) 121 | 122 | -- | Validate a variant of form field inputs by providing a record of validation 123 | -- | functions, one per possible case in the variant. Used for effectful 124 | -- | validation. It is possible to use `HalogenM` as your validation monad, 125 | -- | which gives you full access to your component state (including form fields). 126 | validateM 127 | :: forall xs r1 r2 r3 inputs validators results m 128 | . RL.RowToList validators xs 129 | => VariantTraverseCases m xs r1 r2 130 | => Row.Union r1 r3 inputs 131 | => Row.Union r2 r3 results 132 | => Applicative m 133 | => Variant inputs 134 | -> Record validators 135 | -> m (Variant results) 136 | validateM = flip Variant.traverse 137 | 138 | -- | A type synonym which represents the result of validating the `input` type 139 | -- | of a form field to produce either its `error` or `output`. 140 | type FieldResult :: Type -> Type -> Type -> Type 141 | type FieldResult input error output = Either error output 142 | 143 | -- | A type synonym which picks the `output` type from a form field. 144 | type FieldOutput :: Type -> Type -> Type -> Type 145 | type FieldOutput input error output = output 146 | 147 | -- | Available settings for controlling the form's behavior. 148 | type FormConfig = { | OptionalFormConfig } 149 | 150 | type InitialFormConfig :: Row Type -> Type -> Type 151 | type InitialFormConfig fields action = 152 | { liftAction :: FormlessAction fields -> action 153 | | OptionalFormConfig 154 | } 155 | 156 | -- | Formless uses queries to notify you when important events happen in your 157 | -- | form. You are expected to handle these queries in your `handleQuery` 158 | -- | function so that Formless works correctly. 159 | -- | 160 | -- | You can use `handleSubmitValidate` or `handleSubmitValidateM` if you only 161 | -- | need to handle form submission and validation events. 162 | -- | 163 | -- | - **Query** 164 | -- | Formless has proxied a query from the parent component to you. You are 165 | -- | expected to handle the query. 166 | -- | 167 | -- | - **Validate** 168 | -- | A field needs to be validated. You are expected to validate the field and 169 | -- | return its validated result. You can use the `validate` and `validateM` 170 | -- | functions provided for Formless to perform validation. 171 | -- | 172 | -- | - **SubmitAttempt** 173 | -- | The form was submitted, but not all fields passed validation. You are 174 | -- | given a record of all form fields along with their validation result. 175 | -- | 176 | -- | - **Submit** 177 | -- | The form was submitted and all fields passed validation. You are given 178 | -- | a record of the validated output types for every field in the form. 179 | -- | 180 | -- | - **Reset** 181 | -- | The form was reset to its initial state. 182 | data FormQuery :: (Type -> Type) -> Row Type -> Row Type -> Row Type -> Type -> Type 183 | data FormQuery query inputs results outputs a 184 | = Query (query a) 185 | | Validate (Variant inputs) (Variant results -> a) 186 | | SubmitAttempt (Record results) a 187 | | Submit (Record outputs) a 188 | | Reset a 189 | 190 | -- | A default implementation for `handleQuery` which only handles successful 191 | -- | submission and validation events, used when you only need non-monadic 192 | -- | validation. See `FormQuery` for all available events in Formless. 193 | handleSubmitValidate 194 | :: forall inputs fields validators results outputs query state action slots output m a 195 | . ({ | outputs } -> H.HalogenM state action slots (FormOutput fields output) m Unit) 196 | -> (Variant inputs -> Record validators -> Variant results) 197 | -> Record validators 198 | -> FormQuery query inputs results outputs a 199 | -> H.HalogenM state action slots (FormOutput fields output) m (Maybe a) 200 | handleSubmitValidate onSubmit validate' validators = case _ of 201 | Submit outputs next -> do 202 | onSubmit outputs 203 | pure $ Just next 204 | Validate changed reply -> do 205 | pure $ Just $ reply $ validate' changed validators 206 | _ -> 207 | pure Nothing 208 | 209 | -- | A default implementation for `handleQuery` which only handles successful 210 | -- | submission and validation events, used when you need monadic validation. 211 | -- | See `FormQuery` for all available events in Formless. 212 | handleSubmitValidateM 213 | :: forall inputs fields validators results outputs query state action slots output m a 214 | . ({ | outputs } -> H.HalogenM state action slots (FormOutput fields output) m Unit) 215 | -> (Variant inputs -> Record validators -> H.HalogenM state action slots (FormOutput fields output) m (Variant results)) 216 | -> Record validators 217 | -> FormQuery query inputs results outputs a 218 | -> H.HalogenM state action slots (FormOutput fields output) m (Maybe a) 219 | handleSubmitValidateM onSubmit validateM' validators = case _ of 220 | Submit outputs next -> do 221 | onSubmit outputs 222 | pure $ Just next 223 | Validate changed reply -> do 224 | validated <- validateM' changed validators 225 | pure $ Just $ reply validated 226 | _ -> 227 | pure Nothing 228 | 229 | -- | Available form-wide actions you can tell Formless to do. 230 | type FormAction fields action = 231 | { setFields :: { | fields } -> action 232 | , reset :: action 233 | , submit :: action 234 | , setConfig :: FormConfig -> action 235 | , handleSubmit :: Event -> action 236 | } 237 | 238 | -- | The summary state of the entire form. 239 | type FormState = 240 | { errorCount :: Int 241 | , submitCount :: Int 242 | , allTouched :: Boolean 243 | } 244 | 245 | -- | The full form context which is provided to you. It includes any component 246 | -- | input you wish to take, along with the current state of the form fields and 247 | -- | the form and a set of actions you can use on the form fields or the form. 248 | type FormContext :: Row Type -> Row Type -> Type -> Type -> Type 249 | type FormContext fields actions input action = 250 | { input :: input 251 | , fields :: { | fields } 252 | , actions :: { | actions } 253 | , formState :: FormState 254 | , formActions :: FormAction fields action 255 | } 256 | 257 | -- | Formless uses `FormOutput` to let you notify it of events it should handle. 258 | -- | 259 | -- | - **Raise** 260 | -- | Tell Formless to proxy the provided output to the parent component. 261 | -- | 262 | -- | - **Eval** 263 | -- | Tell Formless to evaluate an action on the form. Actions are provided to 264 | -- | you via the `FormContext`, which gives you actions you can call on 265 | -- | individual form fields or on the form as a whole. 266 | data FormOutput :: Row Type -> Type -> Type 267 | data FormOutput fields output 268 | = Raise output 269 | | Eval (FormlessAction fields) 270 | 271 | -- | A drop-in replacement for Halogen's `raise` function, which you can use to 272 | -- | proxy output through Formless up to the parent component. 273 | raise 274 | :: forall fields state action slots output m 275 | . output 276 | -> H.HalogenM state action slots (FormOutput fields output) m Unit 277 | raise = H.raise <<< Raise 278 | 279 | -- | Tell Formless to evaluate a Formless action. 280 | eval 281 | :: forall fields state action slots output m 282 | . FormlessAction fields 283 | -> H.HalogenM state action slots (FormOutput fields output) m Unit 284 | eval = H.raise <<< Eval 285 | 286 | -- | Internal actions that Formless evaluates to modify the state of the form. 287 | -- 288 | -- These constructors should never be exported. These actions are only provided 289 | -- to the user via the form context after being constructed by Formless. 290 | data FormlessAction :: Row Type -> Type 291 | data FormlessAction fields 292 | = SubmitForm (Maybe Event) 293 | | ResetForm 294 | | SetForm (Record fields) 295 | | SetFormConfig FormConfig 296 | | ChangeField (Variant fields) INPUT 297 | | BlurField (Variant fields) FocusEvent 298 | | ModifyField (Variant fields) (FieldState INPUT ERROR OUTPUT -> FieldState INPUT ERROR OUTPUT) 299 | | ValidateField (Variant fields) 300 | | ResetField (Variant fields) 301 | 302 | data InternalFormAction :: Row Type -> Type -> Type -> Type 303 | data InternalFormAction fields input output 304 | = Initialize 305 | | Receive input 306 | | HandleForm (FormOutput fields output) 307 | 308 | type InternalFormState :: Row Type -> Row Type -> Type -> Type -> Type 309 | type InternalFormState fields actions input action = 310 | { input :: input 311 | , fieldObject :: FieldObject 312 | , fieldActions :: { | actions } 313 | , formState :: FormState 314 | , formActions :: FormAction fields action 315 | , formConfig :: FormConfig 316 | } 317 | 318 | -- | The Formless higher-order component. Expects a form configuration, the 319 | -- | initial input values for each field in the form, and the component that 320 | -- | you want to provide `FormContext` to (ie. your form component). 321 | -- | 322 | -- | Please see the Formless README.md and examples directory for a thorough 323 | -- | description of this component. 324 | formless 325 | :: forall config inputs fields actions results outputs query action input output m 326 | . MonadEffect m 327 | => MkFieldStates inputs fields 328 | => MkFieldActions fields actions action 329 | => MkFieldResults fields results 330 | => MkFieldOutputs results outputs 331 | => MkConfig config (InitialFormConfig fields action) 332 | => { | config } 333 | -> { | inputs } 334 | -> H.Component (FormQuery query inputs results outputs) (FormContext fields actions input action) (FormOutput fields output) m 335 | -> H.Component query input output m 336 | formless providedConfig initialForm component = H.mkComponent 337 | { initialState 338 | , render: \state -> do 339 | let 340 | context = 341 | { input: state.input 342 | , fields: fromFieldObject state.fieldObject 343 | , actions: state.fieldActions 344 | , formState: state.formState 345 | , formActions: state.formActions 346 | } 347 | HH.slot _inner unit component context HandleForm 348 | , eval: H.mkEval 349 | { initialize: Just Initialize 350 | , receive: Just <<< Receive 351 | , finalize: Nothing 352 | , handleAction: handleAction 353 | , handleQuery: H.query _inner unit <<< Query 354 | } 355 | } 356 | where 357 | _inner :: Proxy "inner" 358 | _inner = Proxy 359 | 360 | initialState :: input -> InternalFormState fields actions input action 361 | initialState input = do 362 | let 363 | initialFullConfig :: InitialFormConfig fields action 364 | initialFullConfig = mkConfig providedConfig 365 | 366 | liftAction :: FormlessAction fields -> action 367 | liftAction = initialFullConfig.liftAction 368 | 369 | initialConfig :: FormConfig 370 | initialConfig = Record.delete (Proxy :: Proxy "liftAction") initialFullConfig 371 | 372 | initialFormState :: FormState 373 | initialFormState = 374 | { submitCount: 0 375 | , errorCount: 0 376 | , allTouched: false 377 | } 378 | 379 | initialFormActions :: FormAction fields action 380 | initialFormActions = 381 | { setFields: liftAction <<< SetForm 382 | , reset: liftAction $ ResetForm 383 | , setConfig: liftAction <<< SetFormConfig 384 | , submit: liftAction $ SubmitForm Nothing 385 | , handleSubmit: liftAction <<< SubmitForm <<< Just 386 | } 387 | 388 | initialFieldStates :: { | fields } 389 | initialFieldStates = mkFieldStates initialForm 390 | 391 | initialFieldActions :: { | actions } 392 | initialFieldActions = mkFieldActions liftAction initialFieldStates 393 | 394 | { input 395 | , fieldObject: toFieldObject initialFieldStates 396 | , fieldActions: initialFieldActions 397 | , formState: initialFormState 398 | , formActions: initialFormActions 399 | , formConfig: initialConfig 400 | } 401 | 402 | handleAction :: InternalFormAction fields input output -> _ 403 | handleAction = case _ of 404 | Initialize -> do 405 | { fieldObject, formConfig } <- H.get 406 | when' formConfig.validateOnMount \_ -> 407 | for_ (getKeys fieldObject) runValidation 408 | 409 | Receive input -> do 410 | { input: oldInput } <- H.get 411 | when' (not (unsafeRefEq oldInput input)) \_ -> 412 | H.modify_ _ { input = input } 413 | 414 | HandleForm (Raise output) -> 415 | H.raise output 416 | 417 | HandleForm (Eval action) -> case action of 418 | SubmitForm mbEvent -> do 419 | for_ mbEvent \event -> 420 | H.liftEffect $ Event.preventDefault event 421 | H.get >>= \{ fieldObject } -> 422 | for_ (getKeys fieldObject) runValidation 423 | H.get >>= \{ fieldObject } -> do 424 | H.modify_ \state -> state { formState { submitCount = state.formState.submitCount + 1 } } 425 | for_ (mkFieldResults (fromFieldObject fieldObject)) \results -> case mkFieldOutputs results of 426 | Nothing -> H.tell _inner unit $ SubmitAttempt results 427 | Just outputs -> H.tell _inner unit $ Submit outputs 428 | 429 | SetForm values -> 430 | H.modify_ \state -> state { fieldObject = toFieldObject values } 431 | 432 | SetFormConfig config -> 433 | H.modify_ \state -> state { formConfig = config } 434 | 435 | ResetForm -> do 436 | let reset field = field { value = field.initialValue, result = Nothing } 437 | H.modify_ \state -> state 438 | { fieldObject = map reset state.fieldObject 439 | , formState = { submitCount: 0, errorCount: 0, allTouched: false } 440 | } 441 | H.tell _inner unit Reset 442 | 443 | ChangeField changed input -> do 444 | { formConfig } <- H.get 445 | let modify = mkFieldRep changed (_ { value = input }) 446 | H.modify_ \state -> state { fieldObject = modifyField modify state.fieldObject } 447 | when' formConfig.validateOnChange \_ -> 448 | runFormAction $ ValidateField changed 449 | 450 | BlurField changed _ -> do 451 | { formConfig } <- H.get 452 | when' formConfig.validateOnBlur \_ -> 453 | runFormAction $ ValidateField changed 454 | 455 | ModifyField changed modifyFn -> do 456 | { formConfig } <- H.get 457 | let modify = mkFieldRep changed modifyFn 458 | H.modify_ \state -> state { fieldObject = modifyField modify state.fieldObject } 459 | when' formConfig.validateOnModify \_ -> 460 | runFormAction $ ValidateField changed 461 | 462 | ValidateField changed -> 463 | runValidation (fieldsKey changed) 464 | 465 | ResetField changed -> do 466 | let 467 | reset field = field { value = field.initialValue, result = Nothing } 468 | modify = mkFieldRep changed reset 469 | H.modify_ \state -> state { fieldObject = modifyField modify state.fieldObject } 470 | 471 | runFormAction :: FormlessAction fields -> H.HalogenM _ _ _ _ _ Unit 472 | runFormAction action = handleAction $ HandleForm $ Eval action 473 | 474 | -- Validation must always be run using this helper function because it is not 475 | -- safe to validate based on a `Variant inputs` alone. Validation always needs 476 | -- to pull the latest field value from object state before validating it. 477 | runValidation :: FieldKey -> H.HalogenM _ _ _ _ m Unit 478 | runValidation fieldKey = do 479 | mbResult <- H.request _inner unit <<< (Validate <<< injInput <<< getField fieldKey) =<< H.gets _.fieldObject 480 | for_ mbResult \resultVariant -> do 481 | let result = coerceResultsRep resultVariant 482 | H.modify_ \state -> do 483 | let fieldObject = setFieldResult result state.fieldObject 484 | state 485 | { fieldObject = fieldObject 486 | , formState = state.formState 487 | { errorCount = countErrors fieldObject 488 | , allTouched = allTouched fieldObject 489 | } 490 | } 491 | 492 | countErrors :: FieldObject -> Int 493 | countErrors = 0 # Object.fold \acc _ { result } -> case result of 494 | Just (Left _) -> acc + 1 495 | _ -> acc 496 | 497 | allTouched :: FieldObject -> Boolean 498 | allTouched = true # Object.fold \acc _ { result } -> case result of 499 | Just _ -> acc && true 500 | _ -> false 501 | 502 | -- The `FieldObject` coercions below are only safe given two conditions: 503 | -- 504 | -- 1. Each value in the `fields` row is a `FieldState` as asserted by `MkFieldStates` 505 | -- 2. The PureScript compiler represents records as objects in JavaScript 506 | toFieldObject :: { | fields } -> FieldObject 507 | toFieldObject = unsafeCoerce 508 | 509 | fromFieldObject :: FieldObject -> { | fields } 510 | fromFieldObject = unsafeCoerce 511 | 512 | -- The `Variant` coercions below are only safe given two conditions: 513 | -- 514 | -- 1. The values in the variant are never read or modified, only the labels. 515 | -- 2. Each value in the `results` variant is a `FieldResult` as asserted by `MkFieldResults` 516 | injInput :: VariantRep INPUT -> Variant inputs 517 | injInput = unsafeCoerce 518 | 519 | fieldsKey :: Variant fields -> FieldKey 520 | fieldsKey = coerceRep >>> \(VariantRep rep) -> coerce rep.type 521 | where 522 | coerceRep :: Variant fields -> VariantRep (FieldState INPUT ERROR OUTPUT) 523 | coerceRep = unsafeCoerce 524 | 525 | coerceResultsRep :: Variant results -> VariantRep (Either ERROR OUTPUT) 526 | coerceResultsRep = unsafeCoerce 527 | 528 | mkFieldRep :: forall a. Variant fields -> a -> VariantRep a 529 | mkFieldRep variant value = VariantRep { type: coerce (fieldsKey variant), value } 530 | 531 | getKeys :: FieldObject -> Array FieldKey 532 | getKeys = coerce <<< Object.keys 533 | 534 | getField :: FieldKey -> FieldObject -> VariantRep INPUT 535 | getField (FieldKey key) object = do 536 | let field = Object.Unsafe.unsafeIndex object key 537 | VariantRep { type: key, value: field.value } 538 | 539 | modifyField 540 | :: VariantRep (FieldState INPUT ERROR OUTPUT -> FieldState INPUT ERROR OUTPUT) 541 | -> FieldObject 542 | -> FieldObject 543 | modifyField (VariantRep rep) object = do 544 | let field = Object.Unsafe.unsafeIndex object rep.type 545 | Object.insert rep.type (rep.value field) object 546 | 547 | setFieldResult :: VariantRep (Either ERROR OUTPUT) -> FieldObject -> FieldObject 548 | setFieldResult (VariantRep rep) object = do 549 | let field = Object.Unsafe.unsafeIndex object rep.type 550 | Object.insert rep.type (field { result = Just rep.value }) object 551 | 552 | -- The types and functions below are intended for internal use only. Some must 553 | -- be exported for the sake of type inference. 554 | 555 | -- The state of the user's form fields is represented as a plain object. This is 556 | -- safe so long as the field object is only ever accessed using fields we can 557 | -- guarantee are part of the `fields` row as asserted by the `MkFieldStates` class. 558 | type FieldObject = Object (FieldState INPUT ERROR OUTPUT) 559 | 560 | foreign import data INPUT :: Type 561 | foreign import data ERROR :: Type 562 | foreign import data OUTPUT :: Type 563 | 564 | newtype FieldKey = FieldKey String 565 | 566 | type OptionalFormConfig = 567 | ( validateOnBlur :: Boolean 568 | , validateOnChange :: Boolean 569 | , validateOnModify :: Boolean 570 | , validateOnMount :: Boolean 571 | ) 572 | 573 | defaultConfig :: { | OptionalFormConfig } 574 | defaultConfig = 575 | { validateOnBlur: true 576 | , validateOnChange: false 577 | , validateOnModify: false 578 | , validateOnMount: false 579 | } 580 | 581 | class Defaults { | OptionalFormConfig } { | config } config' <= MkConfig config config' where 582 | mkConfig :: { | config } -> config' 583 | 584 | instance Defaults { | OptionalFormConfig } { | config } config' => MkConfig config config' where 585 | mkConfig provided = defaults defaultConfig provided 586 | 587 | data MkFieldState = MkFieldState 588 | 589 | class HMap MkFieldState { | inputs } { | fields } <= MkFieldStates inputs fields | inputs -> fields where 590 | mkFieldStates :: { | inputs } -> { | fields } 591 | 592 | instance HMap MkFieldState { | inputs } { | fields } => MkFieldStates inputs fields where 593 | mkFieldStates = hmap MkFieldState 594 | 595 | instance Mapping MkFieldState input (FieldState input error output) where 596 | mapping MkFieldState input = { initialValue: input, value: input, result: Nothing } 597 | 598 | newtype MkFieldAction fields action = MkFieldAction (FormlessAction fields -> action) 599 | 600 | class HMapWithIndex (MkFieldAction fields action) { | fields } { | actions } <= MkFieldActions fields actions action | fields -> actions where 601 | mkFieldActions :: (FormlessAction fields -> action) -> { | fields } -> { | actions } 602 | 603 | instance HMapWithIndex (MkFieldAction fields action) { | fields } { | actions } => MkFieldActions fields actions action where 604 | mkFieldActions lift = hmapWithIndex (MkFieldAction lift) 605 | 606 | instance 607 | ( IsSymbol sym 608 | , TypeEquals (FieldState input error output) field 609 | , Row.Cons sym (FieldState input error output) _1 fields 610 | ) => 611 | MappingWithIndex (MkFieldAction fields action) (Proxy sym) field (FieldAction action input error output) where 612 | mappingWithIndex (MkFieldAction lift) sym _ = do 613 | let 614 | -- We use an the field variant /only/ for access to labels, and /never/ 615 | -- read the value at the label. 616 | fieldVariant :: Variant fields 617 | fieldVariant = Variant.inj sym (unsafeCoerce unit :: FieldState input error output) 618 | 619 | mkInput :: input -> INPUT 620 | mkInput = unsafeCoerce 621 | 622 | mkModify :: (FieldState input error output -> FieldState input error output) -> (FieldState INPUT ERROR OUTPUT -> FieldState INPUT ERROR OUTPUT) 623 | mkModify = unsafeCoerce 624 | 625 | { key: reflectSymbol sym 626 | , modify: lift <<< ModifyField fieldVariant <<< mkModify 627 | , reset: lift $ ResetField fieldVariant 628 | , validate: lift $ ValidateField fieldVariant 629 | , handleChange: lift <<< ChangeField fieldVariant <<< mkInput 630 | , handleBlur: lift <<< BlurField fieldVariant 631 | } 632 | 633 | data MkFieldResult = MkFieldResult 634 | 635 | class HFoldlWithIndex MkFieldResult (Maybe (Builder {} {})) { | fields } (Maybe (Builder {} { | results })) <= MkFieldResults fields results | fields -> results where 636 | mkFieldResults :: { | fields } -> Maybe { | results } 637 | 638 | instance HFoldlWithIndex MkFieldResult (Maybe (Builder {} {})) { | fields } (Maybe (Builder {} { | results })) => MkFieldResults fields results where 639 | mkFieldResults = map (flip Builder.build {}) <<< hfoldlWithIndex MkFieldResult (pure identity :: Maybe (Builder {} {})) 640 | 641 | instance 642 | ( IsSymbol sym 643 | , TypeEquals (FieldState input error output) field 644 | , Row.Lacks sym rb 645 | , Row.Cons sym (Either error output) rb rc 646 | ) => 647 | FoldingWithIndex MkFieldResult (Proxy sym) (Maybe (Builder { | ra } { | rb })) field (Maybe (Builder { | ra } { | rc })) 648 | where 649 | foldingWithIndex MkFieldResult prop rin field = do 650 | let { result } = Type.Equality.from field 651 | (>>>) <$> rin <*> (Builder.insert prop <$> result) 652 | 653 | data MkFieldOutput = MkFieldOutput 654 | 655 | class HFoldlWithIndex MkFieldOutput (Maybe (Builder {} {})) { | results } (Maybe (Builder {} { | outputs })) <= MkFieldOutputs results outputs | results -> outputs where 656 | mkFieldOutputs :: { | results } -> Maybe { | outputs } 657 | 658 | instance HFoldlWithIndex MkFieldOutput (Maybe (Builder {} {})) { | results } (Maybe (Builder {} { | outputs })) => MkFieldOutputs results outputs where 659 | mkFieldOutputs = map (flip Builder.build {}) <<< hfoldlWithIndex MkFieldOutput (pure identity :: Maybe (Builder {} {})) 660 | 661 | instance 662 | ( IsSymbol sym 663 | , TypeEquals (FieldResult input error output) result 664 | , Row.Lacks sym rb 665 | , Row.Cons sym output rb rc 666 | ) => 667 | FoldingWithIndex MkFieldOutput (Proxy sym) (Maybe (Builder { | ra } { | rb })) result (Maybe (Builder { | ra } { | rc })) 668 | where 669 | foldingWithIndex MkFieldOutput prop rin field = do 670 | let result = Type.Equality.from field 671 | (>>>) <$> rin <*> (Builder.insert prop <$> hush result) 672 | 673 | -- A lazy version of `when`, for internal use. 674 | when' :: forall m. Applicative m => Boolean -> (Unit -> m Unit) -> m Unit 675 | when' true k = k unit 676 | when' _ _ = pure unit 677 | --------------------------------------------------------------------------------