Major update to documentation
This commit is contained in:
parent
a415d3da07
commit
d2355cd073
6 changed files with 663 additions and 119 deletions
|
@ -1,3 +1,5 @@
|
|||
title: Free Queries in PureScript & Halogen
|
||||
|
||||
# Understanding Free Monad Queries
|
||||
|
||||
Most of the time Halogen queries look like this:
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
title: PureScript Halogen Select Examples
|
||||
|
||||
# Examples
|
||||
|
||||
You can play around with a few example components here. However, for a much richer set of components with much more functionality, check out the [Ocelot design system by CitizenNet](https://citizennet.github.io/purescript-ocelot/#typeaheads).
|
||||
|
||||
!!! warning
|
||||
The components on this page function properly, but look horrible while we migrate CSS.
|
||||
|
||||
### Dropdown
|
||||
|
||||
Dropdowns are a common button-driven input type, especially for navigation. But most custom dropdowns sacrifice usability: unlike browser default dropdowns, you can't type on most custom dropdowns, nor are many built with accessibility in mind. With `Select` you can easily create rich, usable dropdowns with little code.
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
title: Documentation for PureScript Halogen Select
|
||||
|
||||
# Welcome
|
||||
|
||||
`Select` helps you build selection user interfaces in PureScript with Halogen. You can use it to build dropdowns, typeaheads and autocompletes, date pickers, image pickers, and more, with features like keyboard navigation, accessibility, and state management handled for you. This library takes a unique approach to component design to ensure you can leverage its features without compromising your design in any way.
|
||||
|
@ -16,9 +18,7 @@ $ bower install --save purescript-halogen-select
|
|||
|
||||
## Quick Start
|
||||
|
||||
If this is your first time using `Select`, start with the [tutorials](https://citizennet.github.io/purescript-halogen-select/tutorials/getting-started). I'd recommend starting with the simplest example where you'll learn to make this dropdown component:
|
||||
|
||||
<div class="ocelot-scoped" data-component="dropdown"></div>
|
||||
If this is your first time using `Select`, start with the [tutorials](https://citizennet.github.io/purescript-halogen-select/tutorials/getting-started). I'd recommend starting with the simplest example where you'll learn to make a keyboard-navigable dropdown component:
|
||||
|
||||
!!! tip
|
||||
Don't want to build your own UI components? Check out the [Ocelot component library](https://citizennet.github.io/purescript-ocelot)!
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
# Let's Build a Dropdown
|
||||
title: Build a PureScript Dropdown in Halogen
|
||||
|
||||
# Let's Build a Dropdown in PureScript!
|
||||
|
||||
Dropdowns are among the simplest selection components you will build, but they can be tricky to get right. For example, you'll likely want to ensure that your users can type to highlight close text matches (like when you type "Ca" to highlight "California" in a state dropdown). You'll want to be accessible to folks using screen readers or keyboard-only navigation, too. And, of course, you'll want to achieve all this without compromising on your design.
|
||||
|
||||
This tutorial is intended as a beginner-friendly, thorough introduction to `Select`. We'll build a functional dropdown complete with keyboard navigation. Along the way, we'll learn more about how to work with Halogen components, diagnose type errors, and other common PureScript tasks.
|
||||
|
||||
|
||||
!!! info ""
|
||||
!!! info
|
||||
This tutorial assumes you've followed the steps in the [Project Setup](https://citizennet.github.io/purescript-halogen-select/tutorials/getting-started/) section. While not necessary, this code is tested with those steps in mind.
|
||||
|
||||
It also assumes familiarity with the Halogen framework. If you need a refresher, try the official [Halogen guide](https://github.com/slamdata/purescript-halogen/tree/master/docs) or the [whirlwind tour](https://citizennet.github.io/purescript-halogen-select/tutorials/getting-started/#a-whirlwind-tour-of-our-starter-component) of our starter component.
|
||||
|
@ -110,7 +111,7 @@ dropdown =
|
|||
]
|
||||
```
|
||||
|
||||
!!! tip ""
|
||||
!!! tip
|
||||
Since the dropdown has no behavior yet, try changing the `#!hs initialState` to set `#!hs isOpen` to `#!hs true` to verify your items are in fact being rendered out to the page.
|
||||
|
||||
It ain't pretty, but at this point we've got all the rendering we need for a basic dropdown! The next step is to actually wire in some behavior.
|
||||
|
@ -137,7 +138,7 @@ import Select as Select
|
|||
import Select.Utils.Setters as Setters
|
||||
```
|
||||
|
||||
!!! tip ""
|
||||
!!! tip
|
||||
You can always [view the module documentation for Select on Pursuit](https://pursuit.purescript.org/packages/purescript-halogen-select/1.0.0) or the [source code on GitHub](https://github.com/citizennet/purescript-halogen-select). This is useful when integrating with third-party components so that you can check out the `#!hs Input`, `#!hs State`, `#!hs Query`, and `#!hs Message` types.
|
||||
|
||||
Next, we need to update our `#!hs ChildSlot` and `#!hs ChildQuery` types. We're only going to have one dropdown so we can leave the child slot as `#!hs Unit`; we do need to add the `Select` component's query type to our `#!hs ChildQuery` synonym, however.
|
||||
|
@ -562,7 +563,7 @@ in value declaration component
|
|||
|
||||
This time it's because we've added a new query, but we never updated our `#!hs eval` function to describe what should happen when the query is triggered. What should we actually _do_ when a message comes up from the child component?
|
||||
|
||||
!!! tip ""
|
||||
!!! tip
|
||||
You'll often see type errors that end in "... value declaration component." when the error occurred in any of the functions in the `where` clause for the component. It can be annoying to track down where the error actually is in your code. One way to help track these down is to move your code out of the `where` block and into the module level temporarily so the compiler can identify which particular function is causing the issue.
|
||||
|
||||
There are four possible sub-cases that we need to handle, each described in the module documentation:
|
||||
|
@ -632,6 +633,9 @@ Nice and simple! While you may write all kinds of logic for the other messages r
|
|||
|
||||
Congratulations! You have successfully built a keyboard-navigable dropdown using `Select`. You integrated the library, wrote your own render function, and then augmented it with helper functions from the library. Then, you handled the output messages and sent queries to update the component's state. You've done quite a lot of work!
|
||||
|
||||
!!! tip
|
||||
Did you notice anything you would improve about this tutorial or the `Select` library? I'd love to hear about it! Feel free to reach out on the [functional programming Slack](https://functionalprogramming.slack.com/) or on the [PureScript user forum](https://purescript-users.ml). If you found a bug or would like to make an improvement, please open an issue or pull request on the library.
|
||||
|
||||
### Next Steps
|
||||
|
||||
This tutorial was a slow, thorough introduction to the `Select` library. But we've only scratched the surface of what you can do with it. I'd recommend continuing on to the faster-paced and more advanced [typeahead tutorial](https://citizennet.github.io/purescript-halogen-select/tutorials/typeahead).
|
||||
|
@ -659,12 +663,10 @@ If you'd like to use this component as a starting point from which to build your
|
|||
import Select.Utils.Setters as Setters
|
||||
|
||||
data Query a
|
||||
= NoOp a
|
||||
| HandleSelect (Select.Message Query String) a
|
||||
= HandleSelect (Select.Message Query String) a
|
||||
|
||||
type State =
|
||||
{ isOpen :: Boolean
|
||||
, selectedItem :: Maybe String
|
||||
{ selectedItem :: Maybe String
|
||||
, availableItems :: Array String
|
||||
}
|
||||
|
||||
|
@ -747,8 +749,6 @@ If you'd like to use this component as a starting point from which to build your
|
|||
|
||||
eval :: Query ~> H.ParentDSL State Query (ChildQuery (Effects eff)) ChildSlot Message m
|
||||
eval = case _ of
|
||||
NoOp next -> pure next
|
||||
|
||||
HandleSelect message next -> case message of
|
||||
Select.Searched string ->
|
||||
pure next
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
title: Project Setup for PureScript Halogen Select
|
||||
|
||||
# Introduction
|
||||
Halogen is a powerful framework for building PureScript applications. It’s used by several companies, including SlamData and my own company, CitizenNet (a Condé Nast company), among others. The `Select` library is written for the Halogen framework, so if you don’t know how to use Halogen yet, you ought to start with the [Halogen guide](https://github.com/slamdata/purescript-halogen/tree/master/docs). That said, with only passing familiarity with Halogen, you should be able to follow along just fine!
|
||||
|
||||
## Setup
|
||||
Instead of creating a new Halogen project from scratch, we’ll start with a minimal starter template. This template includes the HTML, build scripts, and basic `Main.purs` file necessary to run your Halogen application. It also includes a component with the bare minimum definitions in place. This component does nothing at all, which is nice because we can easily use it to start building dropdowns, typeaheads, and other components.
|
||||
|
||||
!!! note ""
|
||||
!!! info
|
||||
We prefer Yarn over NPM for package management and scripts, but either one will work. Anywhere you see `#!sh yarn <script>`, you can substitute `#!sh npm run <script>` instead. Feel free to look at the `package.json` file if you want to see what these scripts are doing.
|
||||
|
||||
### Installation
|
||||
|
|
|
@ -1,7 +1,564 @@
|
|||
# Let's build a typeahead!
|
||||
title: Build a PureScript Typeahead (Autocomplete) in Halogen
|
||||
|
||||
# Let's Build a Typeahead in PureScript!
|
||||
|
||||
Typeaheads are among the most common selection components you'll build. Most web developers have had to implement at least one of these before and they can be surprisingly difficult to build. Luckily, with `Select`, implementing a typeahead that fits your custom design takes little more than writing the rendering code and then tweaking it with a helper function or two.
|
||||
|
||||
In this tutorial we'll build a typeahead with the following features:
|
||||
|
||||
- Users can search Star Wars characters by name; their searches will be debounced automatically and results will be fetched asynchronously.
|
||||
- The typeahead should support keyboard-only use: arrow keys should step up and down the items, Enter should select, Escape should close, and so on.
|
||||
- The typeahead should manage its own selections, including insertion and removal, and should notify its parent when the selections have changed.
|
||||
- If a search returns no results, then there should be an embedded "fetch data" button the user can click to force a request with an empty search. It should display within the list of items.
|
||||
|
||||
Along the way, we'll see how to extend `Select`'s features by embedding parent queries (we'll use this to embed the "fetch data" button in the list).
|
||||
|
||||
!!! info
|
||||
This tutorial assumes you've followed the steps in the [Project Setup](https://citizennet.github.io/purescript-halogen-select/tutorials/getting-started/) section. While not necessary, this code is tested with those steps in mind.
|
||||
|
||||
It also assumes familiarity with Halogen and intermediate PureScript experience or that you have already completed the [more thorough, beginner-friendly dropdown tutorial](https://citizennet.github.io/purescript-halogen-select/tutorials/dropdown). If you need a Halogen refresher, try the official [Halogen guide](https://github.com/slamdata/purescript-halogen/tree/master/docs) or the [whirlwind tour](https://citizennet.github.io/purescript-halogen-select/tutorials/getting-started/#a-whirlwind-tour-of-our-starter-component) of our starter component.
|
||||
|
||||
Your code should work at the end of every step. If you run into issues or your code doesn't compile, please come visit us on the [PureScript user forum](https://purescript-users.ml) or the [#fpchat Slack channel](https://functionalprogramming.slack.com).
|
||||
|
||||
|
||||
## Basic Setup
|
||||
|
||||
In this tutorial, we'll build a typeahead component from scratch. You can either follow along using the minimal component from the [Project Setup section](https://github.citizennet.io/purescript-halogen-select/tutorials/getting-started) or start your own.
|
||||
|
||||
If you didn't follow the project setup, grab the source for our starting component here:
|
||||
|
||||
??? article "Source code for a minimal starting component"
|
||||
```hs
|
||||
module Component where
|
||||
|
||||
import Prelude
|
||||
|
||||
import Control.Monad.Aff.Class (class MonadAff)
|
||||
import Data.Const (Const)
|
||||
import Data.Maybe (Maybe(..))
|
||||
import Halogen as H
|
||||
import Halogen.HTML as HH
|
||||
|
||||
data Query a
|
||||
= NoOp a
|
||||
|
||||
type State = Unit
|
||||
type Input = Unit
|
||||
|
||||
type Message = Void
|
||||
|
||||
type ChildSlot = Unit
|
||||
type ChildQuery = Const Void
|
||||
|
||||
component :: ∀ eff m
|
||||
. MonadAff eff m
|
||||
=> H.Component HH.HTML Query Input Message m
|
||||
component =
|
||||
H.parentComponent
|
||||
{ initialState
|
||||
, render
|
||||
, eval
|
||||
, receiver: const Nothing
|
||||
}
|
||||
where
|
||||
|
||||
initialState :: Input -> State
|
||||
initialState = const unit
|
||||
|
||||
render :: State -> H.ParentHTML Query ChildQuery ChildSlot m
|
||||
render st = HH.div_ []
|
||||
|
||||
eval :: Query ~> H.ParentDSL State Query ChildQuery ChildSlot Message m
|
||||
eval = case _ of
|
||||
NoOp next -> pure next
|
||||
```
|
||||
|
||||
### Install dependencies
|
||||
|
||||
The first thing we'll do is make sure we have the libraries we need installed. Our typeahead is going to make API calls on our behalf, decode the response, and keep track of the state of requests using a special `#!hs RemoteData` data type. Let's go ahead and install our dependencies:
|
||||
|
||||
```shell
|
||||
# These should already be installed as part of the project setup
|
||||
bower i --save purescript-halogen purescript-halogen-select purescript-affjax
|
||||
|
||||
# These are new dependencies
|
||||
bower i --save purescript-argonaut purescript-remotedata
|
||||
|
||||
# Let's compile the new dependencies to ensure they're available to import
|
||||
yarn build
|
||||
```
|
||||
|
||||
### Integrate the component
|
||||
|
||||
Now let's make sure we have `Select` ready to go in our component. Import the library:
|
||||
|
||||
```hs
|
||||
import Select as Select
|
||||
import Select.Utils.Setters as Setters
|
||||
```
|
||||
|
||||
Next, since `Select` is going to be a child component, we'll need to update several types and functions. We will:
|
||||
|
||||
- Delete the unnecessary `#!hs NoOp` query and relevant case in `#!hs eval`
|
||||
- Add a new query to handle messages emitted by `Select`
|
||||
- Update our `#!hs ChildQuery` type synonym to contain `Select`'s query type
|
||||
- Update the type signatures for `#!hs eval` and `#!hs render` with the new `#!hs ChildQuery`
|
||||
- Add a new case to `#!hs eval` for our new `#!hs HandleSelect` query
|
||||
|
||||
!!! tip
|
||||
This tutorial doesn't explain things like child queries, slots, inputs, rendering, `#!hs Free`, `#!hs eval` functions, or other crucial Halogen knowledge. If you feel lost, I'd recommend checking out the [dropdown tutorial](https://citizennet.github.io/purescript-halogen-select/tutorials/dropdown) before continuing.
|
||||
|
||||
Of course, we won't be prepared to handle messages or use `Select`'s queries without knowing what they are. Let's start with the query type for the `Select` component, `#!hs QueryF`:
|
||||
|
||||
```hs
|
||||
-- | - `o`: The query type of the component that will mount this component in a child slot.
|
||||
-- | This allows you to embed your own queries into the `Select` component.
|
||||
-- | - `item`: Your custom item type. It can be a simple type like `String`, or something
|
||||
-- | complex like `CalendarItem StartDate EndDate (Maybe Disabled)`.
|
||||
-- | - `eff`: The component's effects.
|
||||
data QueryF o item eff a
|
||||
= Search String a
|
||||
| Highlight Target a
|
||||
| Select Int a
|
||||
| CaptureRef ET.Event a
|
||||
| Focus Boolean a
|
||||
| Key KE.KeyboardEvent a
|
||||
| PreventClick ME.MouseEvent a
|
||||
| SetVisibility Visibility a
|
||||
| GetVisibility (Visibility -> a)
|
||||
| ReplaceItems (Array item) a
|
||||
| Raise (o Unit) a
|
||||
| Receive (Input o item eff) a
|
||||
```
|
||||
|
||||
Already we're faced with an interesting decision: how should we fill in the type variables that `Select` expects? Two of them are straightforward:
|
||||
|
||||
- `o` represents the type of queries that can be embedded in the component. You should fill this in with your parent component's query type. If you follow Halogen convention and name your type `#!hs Query`, then filling this variable in will produce `#!hs Select.Query Query item eff`. If you take a look at where this variable is used, you'll see it shows up in the `Select` component's `#!hs Raise` and `#!hs Receive` queries. The `#!hs Raise` query is a wrapper that you can use to embed your query into the render function you provide to the component. The `#!hs Receive` query leverages `Select`'s `#!hs Input` type, which includes that render function. I'll have a lot more to say about embedding your own query type into `Select` later on.
|
||||
- `eff` is the effect row that the `Select` component is going to use. The component uses `#!hs DOM` and `#!hs AVAR` effects. `#!hs eff` is required for the `#!hs State` type, which is itself in the type for the render function, which is itself passed to `Select` via the component's `#!hs Input` type, which is handled with the `#!hs Receive` query, and so you'll see the type variable present in all the key component types.
|
||||
|
||||
The second type argument is more interesting. `Select` allows you to provide any type as your selectable "item". While in this tutorial we're going to stick with strings you could very well make a significantly more information-rich type.
|
||||
|
||||
??? tip "Writing useful item types"
|
||||
Any time you need to render some items differently than others, or you need different logic for when one item is selected vs. another, you should encode that information in the item type. For example, at CitizenNet, our calendar component has an item type like this:
|
||||
|
||||
```hs
|
||||
data CalendarItem = CalendarItem SelectedStatus DisabledStatus Boundary Range Date
|
||||
```
|
||||
|
||||
These custom types give us everything we need to know to render various dates and handle them when selected. For example, if you want some items to be selectable and others to be disabled, you could create an item type like this:
|
||||
|
||||
```hs
|
||||
data Item = Selectable String | Disabled String
|
||||
|
||||
renderItem ix (Selectable str)
|
||||
= HH.li ( Setters.setItemProps ix [ ] ) [ HH.text str ]
|
||||
renderItem _ (Disabled str)
|
||||
= HH.li_ [ HH.text str ]
|
||||
```
|
||||
|
||||
With all this information in mind, let's go ahead and make those changes:
|
||||
|
||||
```hs
|
||||
data Query a
|
||||
= HandleSelect (Select.Message Query String) a
|
||||
|
||||
type ChildSlot = Unit
|
||||
type ChildQuery eff = Select.Query Query String eff
|
||||
|
||||
component =
|
||||
...
|
||||
render :: State -> H.ParentHTML Query (ChildQuery eff) ChildSlot m
|
||||
render st = HH.div_ []
|
||||
|
||||
eval :: Query ~> H.ParentDSL State Query (ChildQuery eff) ChildSlot Message m
|
||||
eval = case _ of
|
||||
-- We'll just stub this out for the time being.
|
||||
HandleSelect message next -> pure next
|
||||
```
|
||||
|
||||
Next, we'll actually mount the `Select` component. We have everything except for the component's `#!hs Input` type so far, so we'll fill that in and leave the input as a type hole.
|
||||
|
||||
```hs
|
||||
import Halogen.HTML.Events as HE
|
||||
|
||||
render :: State -> H.ParentHTML Query (ChildQuery eff) ChildSlot m
|
||||
render st =
|
||||
HH.div_
|
||||
[ HH.slot unit Select.component ?input (HE.input HandleSelect) ]
|
||||
```
|
||||
|
||||
Right away we get a type error:
|
||||
|
||||
```hs
|
||||
Error found in module Component
|
||||
|
||||
Could not match type
|
||||
|
||||
( avar :: AVAR
|
||||
, dom :: DOM
|
||||
| t2
|
||||
)
|
||||
|
||||
with type
|
||||
|
||||
eff0
|
||||
|
||||
while trying to match type
|
||||
|
||||
QueryF Query String
|
||||
( avar :: AVAR
|
||||
, dom :: DOM
|
||||
| t2
|
||||
)
|
||||
|
||||
with type QueryF Query String eff0
|
||||
|
||||
in value declaration component
|
||||
```
|
||||
|
||||
It's an easy fix: we've stated that our component can use any row of effects, but `Select` requires at least `#!hs AVAR` and `#!hs DOM`. The component uses `#!hs AVAR` in order to implement debouncing, and since it manipulates the DOM, it needs the relevant effect. We need to add these effects to the parent component, too. We already know our component is going to make API calls on our behalf, so we'll add the `#!hs AJAX` effect as well.
|
||||
|
||||
Once we've created our `Effects` type, we'll need to apply it to every function that uses the `#!hs eff` variable (but not to type synonyms).
|
||||
|
||||
??? info "Why apply `Effects` to function signatures, not type synonyms?"
|
||||
Effect rows can be surprisingly finicky to get right, especially when you have multiple levels of components and your code becomes more complex. Type synonyms help make your functions more readable by hiding unnecessary details, but in the case of effect rows, they tend to make it much more difficult to debug issues when two rows that are meant to unify don't. At CitizenNet, we follow a rule of thumb to always apply effects in function signatures, but not in type synonyms.
|
||||
|
||||
That's why, as a general rule, you won't see this in our code:
|
||||
|
||||
```hs
|
||||
type ChildQuery eff = Select.Query Query String (Effects eff)
|
||||
```
|
||||
|
||||
```hs
|
||||
import Control.Monad.Aff.AVar (AVAR)
|
||||
import DOM (DOM)
|
||||
import Network.HTTP.Affjax (AJAX)
|
||||
|
||||
type Effects eff =
|
||||
( avar :: AVAR
|
||||
, dom :: DOM
|
||||
, ajax :: AJAX
|
||||
| eff
|
||||
)
|
||||
|
||||
component :: ∀ eff m
|
||||
. MonadAff (Effects eff) m
|
||||
=> H.Component HH.HTML Query Input Message m
|
||||
|
||||
render :: State -> H.ParentHTML Query (ChildQuery (Effects eff)) ChildSlot m
|
||||
eval :: Query ~> H.ParentDSL State Query (ChildQuery (Effects eff)) ChildSlot Message m
|
||||
```
|
||||
|
||||
With that out of the way, we can turn to the component's input type. Here's what we're required to fill in, as per the [`Select` module documentation](https://pursuit.purescript.org/packages/purescript-halogen-select/1.0.0/docs/Select#t:Input):
|
||||
|
||||
```hs
|
||||
-- | Text-driven inputs will operate like a normal search-driven selection component.
|
||||
-- | Toggle-driven inputs will capture key streams and debounce in reverse (only notify
|
||||
-- | about searches when time has expired).
|
||||
data InputType
|
||||
= TextInput
|
||||
| Toggle
|
||||
|
||||
-- | The component's input type, which includes the component's render function. This
|
||||
-- | render function can also be used to share data with the parent component, as every
|
||||
-- | time the parent re-renders, the render function will refresh in `Select`.
|
||||
type Input o item eff =
|
||||
{ inputType :: InputType
|
||||
, items :: Array item
|
||||
, initialSearch :: Maybe String
|
||||
, debounceTime :: Maybe Milliseconds
|
||||
, render :: State item eff -> ComponentHTML o item eff
|
||||
}
|
||||
```
|
||||
|
||||
Let's look at these one-by-one:
|
||||
|
||||
1. We're using an input field in the DOM, so we'll use the `#!hs TextInput` type to drive the component.
|
||||
2. We don't have any items yet (they'll be fetched via the Star Wars API), so we'll provide an empty array.
|
||||
3. We don't want there to be an initial search; we'll wait for the user to type something. However, if at any point we want to fill in text in the input field (for example, set the text to the full selection when the user selects something), we can use this field to accomplish that.
|
||||
4. We're making API calls every time the user performs a search, so we'll set a reasonable debounce time of a few hundred milliseconds.
|
||||
5. Ah, the big issue: we need to write a render function and pass it in to the component. We don't have one yet, so we'll stub this out with a simple empty `#!hs div`.
|
||||
|
||||
Let's write that input record now:
|
||||
|
||||
```hs
|
||||
import Data.Time.Duration (Milliseconds(..))
|
||||
|
||||
render :: State -> H.ParentHTML Query (ChildQuery (Effects eff)) ChildSlot m
|
||||
render st =
|
||||
HH.div_
|
||||
[ HH.slot unit Select.component selectInput (HE.input HandleSelect) ]
|
||||
|
||||
selectInput :: Select.Input Query String (Effects eff)
|
||||
selectInput =
|
||||
{ inputType: Select.TextInput
|
||||
, items: []
|
||||
, initialSearch: Nothing
|
||||
, debounceTime: Just $ Milliseconds 250.0
|
||||
, render: \_ -> HH.div_ [ HH.text "Not implemented" ]
|
||||
}
|
||||
```
|
||||
|
||||
All right! We've fully integrated the `Select` component. It's a little tedious to integrate the component the first time you do it, but it soon becomes second nature. At this point, we're ready to start writing our typeahead.
|
||||
|
||||
!!! danger ""
|
||||
Now would be a good time to verify that this component is rendering properly. Compile the project and point your browser to `dist/index.html`. You should see text rendering from within `Select`.
|
||||
|
||||
## A minimal typeahead
|
||||
|
||||
Let's take a step back now that we have `Select` integrated. We are building a typeahead that will fetch some data asynchronously when the user makes a search. It needs to maintain a list of items that can be selected, and a list of items that have already been selected. The user should only be able to select any item once, so these two lists should have no shared items. We'd like the typeahead to handle all the data fetching and selections behind the scenes, and only notify the parent component when the selections have been updated.
|
||||
|
||||
With this information in mind, we can step through the key data types in our Halogen component and ensure they accurately capture the features we want.
|
||||
|
||||
!!! note
|
||||
In the dropdown tutorial, we started by writing a render function and only later worried about state, queries, messages, and so on. However, I usually like to work in the other direction. We already know the behaviors and data we need to manage, and we don't need to render anything to implement them, though we'll certainly use our rendered component for testing.
|
||||
|
||||
Instead, we'll work through the major data types in our component and only once those are completed will we write some minimal rendering code. It might feel a little strange to spend so much time on data without once touching the HTML, but by the time we reach our rendering function it will naturally extend from the data.
|
||||
|
||||
|
||||
### State
|
||||
|
||||
From our requirements, we know we'll need some information:
|
||||
|
||||
- A list of items that can be selected by the user
|
||||
- A list of items that have already been selected, and which can be removed
|
||||
- The user's last search, so we can use it to fetch new data from the Star Wars API
|
||||
|
||||
It'll also be nice to have some extra information purely for rendering purposes, like:
|
||||
|
||||
- An indicator as to whether the menu should be displayed or not
|
||||
- An indicator as to which item the user has focused, so we can highlight it
|
||||
|
||||
We have access to two distinct `#!hs State` types when we use `Select`: the parent component state, which we own, and the `Select` component's state, which we can read and write. There's no point in duplicating information between the two if we can help it. But we have access to even more information: messages output by the component. Sometimes we can simply rely on the contents of these messages to take action without ever storing the result in state.
|
||||
|
||||
Let's take a quick look at what `Select` provides (take a look at the [module documentation](https://pursuit.purescript.org/packages/purescript-halogen-select/1.0.0/docs/Select#t:State) for more details):
|
||||
|
||||
```hs
|
||||
type State item eff =
|
||||
{ inputType :: InputType
|
||||
, search :: String
|
||||
, debounceTime :: Milliseconds
|
||||
, debouncer :: Maybe (Debouncer eff)
|
||||
, inputElement :: Maybe HTMLElement
|
||||
, items :: Array item
|
||||
, visibility :: Visibility
|
||||
, highlightedIndex :: Maybe Int
|
||||
, lastIndex :: Int
|
||||
}
|
||||
|
||||
data Message o item
|
||||
= Searched String
|
||||
| Selected item
|
||||
| VisibilityChanged Visibility
|
||||
| Emit (o Unit)
|
||||
```
|
||||
|
||||
It looks like we already have the list of selectable items stored in `Select`, so we don't need that in our component state. We're fetching new items via an external API, so after each new search we can simply pass the new items straight down.
|
||||
|
||||
We also have the user's search stored in `State` and also raised as a message every time the debouncer runs out. We don't really care about every keystroke the user types, so we'll rely on the `#!hs Searched` message for this information.
|
||||
|
||||
We have our two pieces of rendering information, too, with the `#!hs visibility` and `#!hs highlightedIndex` fields.
|
||||
|
||||
In fact, it looks like the only thing we have to store in our `#!hs State` is the list of selecetions! That keeps things simple.
|
||||
|
||||
```hs
|
||||
type State = { selections :: Array String }
|
||||
```
|
||||
|
||||
!!! tip
|
||||
`Select` doesn't manage any selections on your behalf. What should happen when an item gets selected, after all? In some cases, you might want to stick it into a "selected" list and remove it from the list of available options. In others, you might want it to be selectable multiple times. Or you might want to just apply a highlight, like in a calendar picker. Rather than force you to fill out a configuration record, `Select` defers the decision to you.
|
||||
|
||||
This is OK, but I'd like some more information. We're fetching data asynchronously, right? That means that requests could possibly fail, or they might be in progress for a long time, or perhaps they might never get triggered in the first place. Ideally our typeahead could render differently depending on these states. If we don't keep track of our requests in `#!hs State`, we won't have any of this information available for rendering.
|
||||
|
||||
It's the same idea as using an information-rich custom `item` type to add nuance to your rendering code. Luckily, there already exists a lovely package named `purescript-remotedata` that supplies us with a data type we can use to model each of these states:
|
||||
|
||||
```hs
|
||||
data RemoteData e a
|
||||
= NotAsked
|
||||
| Loading
|
||||
| Failure e
|
||||
| Success a
|
||||
```
|
||||
|
||||
So while it's not strictly necessary to maintain a list of items in our state, we'll leverage `#!hs RemoteData` to have a more useful state type.
|
||||
|
||||
```hs
|
||||
type State =
|
||||
{ items :: RemoteData String (Array String)
|
||||
, selections :: Array String
|
||||
}
|
||||
|
||||
initialState :: Input -> State
|
||||
initialState = const
|
||||
{ items: NotAsked
|
||||
, selections: []
|
||||
}
|
||||
```
|
||||
|
||||
### Query
|
||||
|
||||
Now that we've got a usable `#!hs State` type, let's turn to our queries. Queries are the computations available to the component, so they're the place where we ought to think about what the typeahead should *do*, rather than just how it should render.
|
||||
|
||||
Just like `#!hs State`, when we write our own `#!hs Query` type on top of `Select`, we should consider what is already available in the component. As usual, we'll turn to the [module documentation](https://pursuit.purescript.org/packages/purescript-halogen-select/1.0.0/docs/Select#t:QueryF) to look at our available queries. I'd recommend scrolling through the available functions to get a glimpse of what `Select` offers, but we'll skip to the main points here.
|
||||
|
||||
`Select` is going to manage all the keyboard events, text input, debouncing, moving the highlighted index, and so on. On top of that, we'll need to add some extra functionality: the ability to remove items that have already been selected, and the ability to fetch new items when the user performs a search. We'll at least need two queries to handle these two features.
|
||||
|
||||
Luckily, though, we already _have_ a query available for when a new search has been performed: our `#!hs HandleSelect` query tied to the `#!hs Select.Searched` message! That means we really only need one new query:
|
||||
|
||||
```hs
|
||||
data Query a
|
||||
= HandleSelect (Select.Message Query String) a
|
||||
| Remove String a
|
||||
|
||||
eval :: Query ~> H.ParentDSL State Query (ChildQuery (Effects eff)) ChildSlot Message m
|
||||
eval = case _ of
|
||||
HandleSelect message next -> case message of
|
||||
Select.Searched str ->
|
||||
pure next
|
||||
Select.Selected item ->
|
||||
pure next
|
||||
Select.VisibilityChanged vis ->
|
||||
pure next
|
||||
Select.Emit query ->
|
||||
pure next
|
||||
|
||||
Remove item next ->
|
||||
pure next
|
||||
```
|
||||
|
||||
What do we want to happen in each of these queries? Let's work from the bottom to the top.
|
||||
|
||||
#### Remove
|
||||
|
||||
When the user clicks on an item that is already selected, we want to remove it from the selected list. We also want to re-insert it into the available items in `Select`. It's easy enough to accomplish this:
|
||||
|
||||
```hs
|
||||
Remove item next -> do
|
||||
H.modify \st -> st { selections = filter (_ /= item) st.selections }
|
||||
st <- H.get
|
||||
_ <- H.query unit
|
||||
$ Select.replaceItems
|
||||
$ difference (withDefault [] st.items) st.selections
|
||||
pure next
|
||||
```
|
||||
|
||||
#### Emit
|
||||
|
||||
What should we do when we get the `#!hs Emit` message? This is returning our own query to us so we can run it, so we can recursively call `#!hs eval` with the query. You'll use this pattern every time you implement a new component with `Select`:
|
||||
|
||||
```hs
|
||||
Select.Emit query -> eval query *> pure next
|
||||
```
|
||||
|
||||
#### VisibilityChange
|
||||
|
||||
What about when the visibility changes? We don't actually care about this one, so we'll ignore it. It's useful for validation, if we were to implement that.
|
||||
|
||||
|
||||
#### Selected
|
||||
|
||||
What about when an item is selected? This one is like the inverse of our `#!hs Remove` query. We want to remove the item from the available items and add it to the list of selections.
|
||||
|
||||
```hs
|
||||
Select.Selected item -> do
|
||||
H.modify \st -> st { selections = item : st.selections }
|
||||
st <- H.get
|
||||
_ <- H.query unit
|
||||
$ Select.replaceItems
|
||||
$ difference (withDefault [] st.items) st.selections
|
||||
pure next
|
||||
```
|
||||
|
||||
#### Searched
|
||||
|
||||
We can finally consider what to do when the user performs a search. We won't do any fancy filtering on our own; we're going to punt that responsibility to an external API. Still, now we have to write the code to fetch that data.
|
||||
|
||||
Our function will hit the Star Wars API, decode the result into an array of strings, and then return them. In the case of failure, we'll return an error message.
|
||||
|
||||
```hs
|
||||
import Data.Argonaut (Json, decodeJson, (.?))
|
||||
import Network.HTTP.Affjax (AJAX, get)
|
||||
|
||||
fetchItems :: String -> Aff (Effects eff) (Either String (Array String))
|
||||
fetchItems str = do
|
||||
(res :: Json) <- _.response
|
||||
<$> get ("https://swapi.co/api/people/?search=" <> str)
|
||||
|
||||
pure $ do
|
||||
obj <- decodeJson res
|
||||
arr <- obj .? "results"
|
||||
traverse (decodeJson <=< flip (.?) "name") arr
|
||||
```
|
||||
|
||||
Now that we have this helper function, we can handle new searches that users perform. First, we'll put our typeahead into the `#!hs Loading` state to represent an ongoing request. Then, we'll empty out the old items in `Select` to avoid out-of-sync data. Then, we'll fetch and decode our items, convert the result from `#!hs Either` to `#!hs RemoteData`, and finally set it on `#!hs State`.
|
||||
|
||||
Once our new items have been set, we can use the result to update `Select` just like we did when we handled new selections or removals.
|
||||
|
||||
```hs
|
||||
Select.Searched string -> do
|
||||
H.modify _ { items = Loading }
|
||||
_ <- H.query unit $ Select.replaceItems []
|
||||
newItems <- H.liftAff (fetchItems string)
|
||||
H.modify _ { items = fromEither newItems }
|
||||
|
||||
st <- H.get
|
||||
_ <- H.query unit
|
||||
$ Select.replaceItems
|
||||
$ difference (withDefault [] st.items) st.selections
|
||||
```
|
||||
|
||||
That's it! Our typeahead has all the logic necessary to function as required. All that's left to do is actually write the render function.
|
||||
|
||||
## Rendering
|
||||
|
||||
We have all the state and behavior necessary to run a working typeahead. Now, let's write the render function. Within the `where` clause of our parent component `#!hs render` function, let's write the render function we're going to pass in to `Select`.
|
||||
|
||||
Note: The explanation for this code is a work in progress. For now, the entire render function is provided below. This will create a functioning typeahead.
|
||||
|
||||
```hs
|
||||
typeahead
|
||||
:: Select.State String (Effects eff)
|
||||
-> Select.ComponentHTML Query String (Effects eff)
|
||||
typeahead childState =
|
||||
HH.div_
|
||||
[ HH.ul_
|
||||
( st.selections <#>
|
||||
(\item ->
|
||||
HH.li
|
||||
[ HE.onClick $ Select.always $ Select.raise $ Remove item unit ]
|
||||
[ HH.text item ]
|
||||
)
|
||||
)
|
||||
, HH.input
|
||||
( Setters.setInputProps [] )
|
||||
, case childState.visibility of
|
||||
Select.Off -> HH.text ""
|
||||
Select.On -> HH.ul (Setters.setContainerProps []) $
|
||||
case length childState.items of
|
||||
0 ->
|
||||
[ HH.li
|
||||
[ HE.onClick
|
||||
$ Select.always
|
||||
$ Select.raise
|
||||
$ HandleSelect (Select.Searched "") unit ]
|
||||
[ HH.text "Fetch new data" ]
|
||||
]
|
||||
_ -> []
|
||||
<>
|
||||
( mapWithIndex
|
||||
(\ix item ->
|
||||
HH.li
|
||||
( Setters.setItemProps ix
|
||||
$ case Just ix == childState.highlightedIndex of
|
||||
true -> [ HP.attr (HH.AttrName "style") "color: red;" ]
|
||||
_ -> [] )
|
||||
[ HH.text item ]
|
||||
)
|
||||
childState.items
|
||||
)
|
||||
]
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
### Next Steps
|
||||
|
@ -16,28 +573,28 @@ Now that you're able to build a typeahead with `Select` you know everything you
|
|||
If you'd like to use this component as a starting point from which to build your own, feel free to copy/paste the source code below.
|
||||
|
||||
??? article "Full source code for the tutorial"
|
||||
|
||||
```hs
|
||||
module Component where
|
||||
|
||||
import Prelude
|
||||
|
||||
import Control.Monad.Aff (Aff, Milliseconds(..))
|
||||
import Control.Monad.Aff (Aff)
|
||||
import Control.Monad.Aff.AVar (AVAR)
|
||||
import Control.Monad.Aff.Class (class MonadAff)
|
||||
import Control.Monad.Aff.Console (CONSOLE)
|
||||
import DOM (DOM)
|
||||
import Data.Argonaut (Json, decodeJson, (.?))
|
||||
import Data.Array (difference, filter, length, mapWithIndex, (:))
|
||||
import Data.Either (Either(..))
|
||||
import Data.Either (Either)
|
||||
import Data.Maybe (Maybe(..))
|
||||
import Data.String (Pattern(..), contains)
|
||||
import Data.Time.Duration (Milliseconds(..))
|
||||
import Data.Traversable (traverse)
|
||||
import Halogen as H
|
||||
import Halogen.HTML as HH
|
||||
import Halogen.HTML.Events as HE
|
||||
import Halogen.HTML.Properties as HP
|
||||
import Halogen.HTML.Properties (attr) as HP
|
||||
import Network.HTTP.Affjax (AJAX, get)
|
||||
import Network.RemoteData (RemoteData(..), withDefault)
|
||||
import Network.RemoteData (RemoteData(..), fromEither, withDefault)
|
||||
import Select as Select
|
||||
import Select.Utils.Setters as Setters
|
||||
|
||||
|
@ -45,26 +602,22 @@ If you'd like to use this component as a starting point from which to build your
|
|||
= HandleSelect (Select.Message Query String) a
|
||||
| Remove String a
|
||||
|
||||
type State eff =
|
||||
type State =
|
||||
{ items :: RemoteData String (Array String)
|
||||
, selections :: Array String
|
||||
, debounceTime :: Milliseconds
|
||||
, fetchItems :: String -> Aff eff (RemoteData String (Array String))
|
||||
}
|
||||
|
||||
type Input = Unit
|
||||
|
||||
data Message
|
||||
= SelectionChange (Array String)
|
||||
type Message = Void
|
||||
|
||||
type ChildSlot = Unit
|
||||
type ChildQuery eff = Select.Query Query String eff
|
||||
|
||||
type Effects eff =
|
||||
( dom :: DOM
|
||||
, avar :: AVAR
|
||||
( avar :: AVAR
|
||||
, dom :: DOM
|
||||
, ajax :: AJAX
|
||||
, console :: CONSOLE
|
||||
| eff
|
||||
)
|
||||
|
||||
|
@ -80,116 +633,101 @@ If you'd like to use this component as a starting point from which to build your
|
|||
}
|
||||
where
|
||||
|
||||
initialState :: Input -> State (Effects eff)
|
||||
initialState :: Input -> State
|
||||
initialState = const
|
||||
{ items: NotAsked
|
||||
, selections: []
|
||||
, debounceTime: Milliseconds 100.0
|
||||
, fetchItems
|
||||
}
|
||||
|
||||
fetchItems
|
||||
:: String
|
||||
-> Aff (Effects eff) (RemoteData String (Array String))
|
||||
fetchItems :: String -> Aff (Effects eff) (Either String (Array String))
|
||||
fetchItems str = do
|
||||
(res :: Json) <- _.response
|
||||
<$> get ("https://swapi.co/api/people/?search=" <> str)
|
||||
|
||||
let items = do
|
||||
obj <- decodeJson res
|
||||
arr <- obj .? "results"
|
||||
traverse (decodeJson <=< flip (.?) "name") arr
|
||||
pure $ do
|
||||
obj <- decodeJson res
|
||||
arr <- obj .? "results"
|
||||
traverse (decodeJson <=< flip (.?) "name") arr
|
||||
|
||||
pure $ case items of
|
||||
Left err -> Failure $ "Could not fetch data:\n\n" <> err
|
||||
Right xs -> Success xs
|
||||
|
||||
render
|
||||
:: State (Effects eff)
|
||||
-> H.ParentHTML Query (ChildQuery (Effects eff)) ChildSlot m
|
||||
render parentState =
|
||||
render :: State -> H.ParentHTML Query (ChildQuery (Effects eff)) ChildSlot m
|
||||
render st =
|
||||
HH.div_
|
||||
[ HH.h1_
|
||||
[ HH.text "Typeahead" ]
|
||||
, HH.slot unit Select.component selectInput (HE.input HandleSelect)
|
||||
]
|
||||
[ HH.slot unit Select.component selectInput (HE.input HandleSelect) ]
|
||||
|
||||
where
|
||||
selectInput :: Select.Input Query String (Effects eff)
|
||||
selectInput =
|
||||
{ inputType: Select.TextInput
|
||||
, items: withDefault [] parentState.items
|
||||
, initialSearch: Nothing
|
||||
, debounceTime: Just parentState.debounceTime
|
||||
, render: typeahead
|
||||
}
|
||||
|
||||
typeahead
|
||||
:: Select.State String (Effects eff)
|
||||
-> Select.ComponentHTML Query String (Effects eff)
|
||||
typeahead childState =
|
||||
HH.div_
|
||||
[ HH.ul_
|
||||
( parentState.selections <#>
|
||||
(\item ->
|
||||
HH.li
|
||||
[ HE.onClick $ Select.always $ Select.raise $ Remove item unit ]
|
||||
[ HH.text item ]
|
||||
)
|
||||
selectInput :: Select.Input Query String (Effects eff)
|
||||
selectInput =
|
||||
{ inputType: Select.TextInput
|
||||
, items: []
|
||||
, initialSearch: Nothing
|
||||
, debounceTime: Just $ Milliseconds 250.0
|
||||
, render: typeahead
|
||||
}
|
||||
|
||||
typeahead
|
||||
:: Select.State String (Effects eff)
|
||||
-> Select.ComponentHTML Query String (Effects eff)
|
||||
typeahead childState =
|
||||
HH.div_
|
||||
[ HH.ul_
|
||||
( st.selections <#>
|
||||
(\item ->
|
||||
HH.li
|
||||
[ HE.onClick $ Select.always $ Select.raise $ Remove item unit ]
|
||||
[ HH.text item ]
|
||||
)
|
||||
, HH.input
|
||||
( Setters.setInputProps [] )
|
||||
, case childState.visibility of
|
||||
Select.Off -> HH.text ""
|
||||
Select.On -> HH.ul (Setters.setContainerProps []) $
|
||||
case length childState.items of
|
||||
0 ->
|
||||
[ HH.li
|
||||
[ HE.onClick
|
||||
$ Select.always
|
||||
$ Select.raise
|
||||
$ HandleSelect (Select.Searched "") unit ]
|
||||
[ HH.text "Fetch new data" ]
|
||||
]
|
||||
_ -> []
|
||||
<>
|
||||
( mapWithIndex
|
||||
(\ix item ->
|
||||
HH.li
|
||||
( Setters.setItemProps ix
|
||||
$ case Just ix == childState.highlightedIndex of
|
||||
true -> [ HP.attr (HH.AttrName "style") "color: red;" ]
|
||||
_ -> [] )
|
||||
[ HH.text item ]
|
||||
)
|
||||
childState.items
|
||||
)
|
||||
, HH.input
|
||||
( Setters.setInputProps [] )
|
||||
, case childState.visibility of
|
||||
Select.Off -> HH.text ""
|
||||
Select.On -> HH.ul (Setters.setContainerProps []) $
|
||||
case length childState.items of
|
||||
0 ->
|
||||
[ HH.li
|
||||
[ HE.onClick
|
||||
$ Select.always
|
||||
$ Select.raise
|
||||
$ HandleSelect (Select.Searched "") unit ]
|
||||
[ HH.text "Fetch new data" ]
|
||||
]
|
||||
_ -> []
|
||||
<>
|
||||
( mapWithIndex
|
||||
(\ix item ->
|
||||
HH.li
|
||||
( Setters.setItemProps ix
|
||||
$ case Just ix == childState.highlightedIndex of
|
||||
true -> [ HP.attr (HH.AttrName "style") "color: red;" ]
|
||||
_ -> [] )
|
||||
[ HH.text item ]
|
||||
)
|
||||
]
|
||||
childState.items
|
||||
)
|
||||
]
|
||||
|
||||
eval
|
||||
:: Query
|
||||
~> H.ParentDSL (State (Effects eff)) Query (ChildQuery (Effects eff)) ChildSlot Message m
|
||||
eval :: Query ~> H.ParentDSL State Query (ChildQuery (Effects eff)) ChildSlot Message m
|
||||
eval = case _ of
|
||||
Remove item next -> do
|
||||
H.modify \st -> st { selections = filter (_ /= item) st.selections }
|
||||
st <- H.get
|
||||
_ <- H.query unit
|
||||
$ Select.replaceItems
|
||||
$ difference (withDefault [] st.items) st.selections
|
||||
pure next
|
||||
H.modify \st -> st { selections = filter (_ /= item) st.selections }
|
||||
st <- H.get
|
||||
_ <- H.query unit
|
||||
$ Select.replaceItems
|
||||
$ difference (withDefault [] st.items) st.selections
|
||||
pure next
|
||||
|
||||
HandleSelect message next -> case message of
|
||||
Select.Searched string -> do
|
||||
st <- H.get
|
||||
|
||||
H.modify _ { items = Loading }
|
||||
_ <- H.query unit $ Select.replaceItems []
|
||||
newItems <- H.liftAff (st.fetchItems string)
|
||||
H.modify _ { items = newItems }
|
||||
newItems <- H.liftAff (fetchItems string)
|
||||
H.modify _ { items = fromEither newItems }
|
||||
|
||||
st <- H.get
|
||||
_ <- H.query unit
|
||||
$ Select.replaceItems
|
||||
$ filter (contains (Pattern string))
|
||||
$ difference (withDefault [] newItems) st.selections
|
||||
$ difference (withDefault [] st.items) st.selections
|
||||
|
||||
pure next
|
||||
|
||||
|
@ -199,13 +737,10 @@ If you'd like to use this component as a starting point from which to build your
|
|||
_ <- H.query unit
|
||||
$ Select.replaceItems
|
||||
$ difference (withDefault [] st.items) st.selections
|
||||
H.raise $ SelectionChange st.selections
|
||||
pure next
|
||||
|
||||
Select.VisibilityChanged vis ->
|
||||
pure next
|
||||
|
||||
Select.Emit query -> do
|
||||
eval query
|
||||
pure next
|
||||
Select.Emit query -> eval query *> pure next
|
||||
```
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue