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
This tutorial assumes you've followed the steps in the Project Setup 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 or the whirlwind tour of our starter component.
If you are already an intermediate or advanced PureScript developer, then this tutorial will read slowly for you. Feel free to skim, get the gist of how the library works, and then move on to the faster-paced and more advanced typeahead tutorial.
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 or the #fpchat Slack channel.
We're going to build a dropdown that is functionally equivalent this one:
Basic Setup¶
Let's get something on the screen!
The simplest sort of dropdown has a button that can toggle a menu open or closed, a list of items that can be selected from that menu, and zero, one, or more selected items. For our dropdown we'll assume that you can select at most one item, and that selecting an item will replace the text on the button with that item.
Rendering a button and items¶
We'll start by rendering the button and the items. At this point our render function contains only an empty div, so let's fill in the rest of the HTML we need:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | render :: State -> H.ParentHTML Query ChildQuery ChildSlot m render st = HH.div_ [ HH.h1_ [ HH.text "Dropdown" ] , HH.button_ [ HH.text "Click me to view some items" ] , HH.ul_ [ HH.li_ [ HH.text "Item 1" ] , HH.li_ [ HH.text "Item 2" ] ] ] |
Make sure to compile this code and view the new output! You should see a header, a button, and two items in the list. After each step, make sure your code still compiles.
A better State
type¶
It's already clear we're going to need more than Unit
for our State
type. We at least need to know three things:
- If the menu is toggled on or off
- The currently-selected item (if there is one)
- The list of items available for selection
We can represent each of these with simple types in our state:
1 2 3 4 5 | type State = { isOpen :: Boolean , selectedItem :: Maybe String , availableItems :: Array String } |
Now that our state contains these three fields, we need to update our initialState
function to produce the right type of values:
1 2 3 4 5 6 7 8 9 10 | initialState :: Input -> State initialState = const { isOpen: false , selectedItem: Nothing , availableItems: [ "Item One" , "Item Two" , "Item Three" ] } |
Finally, lets update our render function to leverage the information now contained in State
. If there's a selected item, that will be the button's text; if not, we'll fall back to a default message. If the menu is open, we'll list out the available items for selection.
For code clarity, we'll also break out the dropdown into its own helper function.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | import Data.Maybe (fromMaybe) render :: State -> H.ParentHTML Query ChildQuery ChildSlot m render st = HH.div_ [ HH.h1_ [ HH.text "Dropdown" ] , dropdown st ] dropdown :: State -> H.ParentHTML Query ChildQuery ChildSlot m dropdown st = HH.div_ [ HH.button_ [ HH.text $ fromMaybe "Click me to view some items" st.selectedItem ] , if st.isOpen then HH.ul_ $ (\item -> HH.li_ [ HH.text item ]) <$> st.availableItems else HH.text "" ] |
Tip
Since the dropdown has no behavior yet, try changing the initialState
to set isOpen
to 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.
Integrating the component¶
Let's integrate the Select
component! In just a few steps, we'll turn our simple render function into a fully-functioning dropdown with keyboard navigation, toggling, debounced type-to-search, and several other features.
On building components with Select¶
The key idea behind the Select
library is to provide behaviors, not rendering. The core component the library exposes doesn't have a render function at all! Of course, all Halogen components require a render function to work, and Select
is no different. You are expected to provide that render function.
Why?
When you write the render function, not the library, you get to decide exactly what your component will look and feel like. You can also control what queries to trigger from HTML and when, effectively allowing you to control the behavior of the component without configuration. You can even extend it with new behavior and new state by using information from your parent component. The end result is a much smaller library component with a lot more flexibility and power for you.
We just wrote the rendering we need for an (admittedly ugly) dropdown. The render function we just wrote can actually serve almost as-is as the render function for Select
! All we have to do is mount the Select
component, make a few tweaks to our render code, and then pass in a little configuration information. Let's do that next.
Importing the Select component¶
The first thing we'll do is bring in the Select
library in the first place.
1 2 | import Select as Select import Select.Setters as Setters |
Tip
You can always view the module documentation for Select on Pursuit or the source code on GitHub. This is useful when integrating with third-party components so that you can check out the Input
, State
, Query
, and Message
types.
Next, we need to update our ChildSlot
and ChildQuery
types. We're only going to have one dropdown so we can leave the child slot as Unit
; we do need to add the Select
component's query type to our ChildQuery
synonym, however.
This code, unfortunately, won't work:
1 2 3 4 5 6 7 | type ChildQuery = Select.Query Error found: Type synonym Select.Query is partially applied. Type synonyms must be applied to all of their type arguments. in type synonym ChildQuery |
The compiler has noticed that ChildQuery
, a type synonym, is partially applied. That's because Select.Query
, itself a type synonym, takes several arguments as described in the module documentation on Pursuit. Let's walk through each one:
1 | type ChildQuery o item = Select.Query o item |
o
is your query type. Remember how you can embed your own queries into Select
, and in that way extend the component's functionality? This is how. So we can fill in the first argument:
1 | type ChildQuery item = Select.Query Query item |
item
is the type of whatever items you want to be selectable. Commonly these are strings, but can also be custom data types. Later on, in the typeahead tutorial, we'll see how powerful custom data types can be for rendering purposes. For our simple dropdown we'll simply specialize this to String
:
1 | type ChildQuery = Select.Query Query String |
Now that Select
has been imported and we've updated our ChildQuery
and ChildSlot
types to support it, we can worry about what to do when we receive a message from the component.
Mounting the component¶
We're finally ready to mount the Select
component. Mounting any component in Halogen requires supplying a slot value, the component itself, the component's input, and the component's handler. We can put together all of these except for the input, which we haven't prepared yet.
Let's stub out our render function in preparation:
1 2 3 4 5 6 7 8 9 | import Halogen.HTML.Events as HE render :: State -> H.ParentHTML Query ChildQuery ChildSlot m render st = HH.div_ [ HH.h1_ [ HH.text "Dropdown" ] , HH.slot unit Select.component ?input (HE.input <<< const Nothing) ] |
With that out of the way, we can turn to filling in our component's input type. We can either look at the module documentation for Select.Input
or look at the type error that resulted from our typed hole, ?input
. Both will tell us that we need to provide a value of this type:
1 2 3 4 5 6 7 | type Input o item = { inputType :: InputType , items :: Array item , initialSearch :: Maybe String , debounceTime :: Maybe Milliseconds , render :: State item -> ComponentHTML o item } |
Let's build this up, step by step. First, we see we have to provide an InputType
. This is described in the module documentation:
1 2 3 4 5 6 | -- | 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 |
We don't have any text input for our dropdown -- its a button -- so we'll go with the Toggle
constructor.
1 2 3 4 5 | selectInput :: Select.Input Query String selectInput = { inputType: Select.Toggle , ... } |
Next, we're expected to provide an array of items. Fortunately we already have those in our State
. We can just send those items directly into the input.
1 2 3 4 | selectInput = { ... , items: st.availableItems } |
Next, we're expected to provide an initial search. This would be more useful if we had a text input, but for our dropdown, we'll start off with no initial search.
1 2 3 4 | selectInput = { ... , initialSearch: Nothing } |
What about a debounce time? For toggle-driven components, this is how long to aggregate key presses before the user's typing should affect the list of items. For search-driven components, this is how long to delay before raising a message with the new search. For our dropdown, we don't care:
1 2 3 4 | selectInput = { ... , debounceTime: Nothing } |
Finally, we're expected to provide a render function to the component. Ah ha! We've actually already written a render function for a dropdown -- it's just that the type is wrong.
Adapting the render function for Select
¶
Let's look at the types side-by-side:
1 2 3 4 5 6 7 8 9 10 11 | Select.render :: Select.State item -> Select.ComponentHTML o item dropdown :: State -> H.ParentHTML Query ChildQuery ChildSlot m dropdown st = HH.div_ [ HH.button_ [ HH.text $ fromMaybe "Click me to view some items" st.selectedItem ] , if st.isOpen then HH.ul_ $ (\item -> HH.li_ [ HH.text item ]) <$> st.availableItems else HH.text "" ] |
From this, we can see that we need to use the state type from Select
to drive our render function, not the state from our parent component. Will our function still work? Let's look at Select
's state type in the module documentation to see what we have available:
1 2 3 4 5 6 7 8 9 10 11 | type State item = { inputType :: InputType , search :: String , debounceTime :: Milliseconds , debouncer :: Maybe Debouncer , inputElement :: Maybe HTMLElement , items :: Array item , visibility :: Visibility , highlightedIndex :: Maybe Int , lastIndex :: Int } |
That's a lot of stuff! We have some of the data we need in Select
's state -- we have our list of items and whether the menu is open or closed. We even got new information, like which item is highlighted. But we're missing something crucial: which item is selected.
As a general rule, Select
does not manage selections on your behalf. You are expected to decide what you want to happen when an item is selected and to store the selections yourself.
What can we do? We don't have all the information we need to write this function. Or do we?
In fact, so long as we write the Select
render function within the where
clause of the parent component's render function, we have access to the parent's state! Let's give it a shot.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | render parentState = HH.div_ [ HH.h1_ [ HH.text "Dropdown" ] , HH.slot unit Select.component ?input (HE.input <<< const Nothing) ] where dropdown :: Select.State String -> Select.ComponentHTML Query String dropdown childState = HH.div_ [ HH.button_ [ HH.text $ fromMaybe "Click me to view some items" parentState.selectedItem ] , if childState.visibility == Select.On then HH.ul_ $ (\item -> HH.li_ [ HH.text item ]) <$> childState.items else HH.text "" ] |
It works! Even better, we no longer have to manage things like openState
in the parent anymore. Finally, now that we have the render function we need, we can finally finish our component's input type:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | render :: State -> H.ParentHTML Query ChildQuery ChildSlot m render parentState = HH.div_ [ HH.h1_ [ HH.text "Dropdown" ] , HH.slot unit Select.component selectInput (HE.input <<< const Nothing) ] where selectInput :: Select.Input Query String selectInput = { inputType: Select.Toggle , items: parentState.availableItems , initialSearch: Nothing , debounceTime: Nothing , render: dropdown } dropdown = ... |
Integrating Behavior¶
Everything up to this point has been standard Halogen except for writing the child component's render function. At this point, the Select
component is running -- good work! However, it's not yet doing anything.
It's now time to turn your static HTML into a fully-functioning dropdown.
Attaching behavior to Select¶
Select
works by using a few helper functions that attach at critical points in your render function. The library assumes very little about what your rendering looks like, except that there at least exists:
- One or more items that can be selected
- An element that contains those items
- A focusable element that can be used to toggle visibility and capture keystrokes
Accordingly, you'll need to use three helper functions, each exported by the Select.Setters
module:
setItemProps
setContainerProps
setToggleProps
(for toggle-driven input)setInputProps
(for text-driven input)
Each of these functions should be used on the property array for the relevant element in your HTML. Let's walk through each one using our built render function.
First, let's augment our individual items. setItemProps
takes an index and some properties and outputs some new properties, which include all sorts of event handlers necessary for keyboard events and click events to work. In order to provide it with the index it needs, we'll use the mapWithIndex
function from Data.Array
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | import Data.Array (mapWithIndex) dropdown childState = HH.div_ [ HH.button_ [ HH.text $ fromMaybe "Click me to view some items" parentState.selectedItem ] , case childState.visibility of Select.Off -> HH.text "" Select.On -> HH.ul [] $ mapWithIndex (\ix item -> HH.li (Setters.setItemProps ix []) [ HH.text item ]) childState.items ] |
Next, we'll move to the element that contains the items. The setContainerProps
function takes and returns some properties, attaching all the behavior the library needs. We'll use this on the parent element, ul
:
1 2 3 4 5 6 7 8 9 10 11 12 | dropdown childState = HH.div_ [ HH.button_ [ HH.text $ fromMaybe "Click me to view some items" parentState.selectedItem ] , case childState.visibility of Select.Off -> HH.text "" Select.On -> HH.ul (Setters.setContainerProps []) $ mapWithIndex (\ix item -> HH.li (Setters.setItemProps ix []) [ HH.text item ]) childState.items ] |
Finally, we can make sure that our button toggles the menu on and off, captures keyboard events, can be tabbed to, and all sorts of other stuff with the setToggleProps
function.
1 2 3 4 5 6 7 8 9 10 11 12 13 | dropdown childState = HH.div_ [ HH.button (Setters.setToggleProps []) [ HH.text $ fromMaybe "Click me to view some items" parentState.selectedItem ] , case childState.visibility of Select.Off -> HH.text "" Select.On -> HH.ul [] $ mapWithIndex (\ix item -> HH.li (Setters.setItemProps ix []) [ HH.text item ]) childState.items ] |
Whew! Your rendering code now contains everything it needs to provide a keyboard-accessible dropdown. If you open this up in the browser and click around, you'll notice it's properly toggling and can be tabbed to.
Let's make one last improvement. When you use your arrow keys on the dropdown, the highlighted index is changing, but since we didn't provide any CSS we can't see it. Let's add some bare-bones styling so we can watch the highlights:
1 2 3 4 5 6 7 8 9 10 11 12 13 | import Halogen.HTML.Properties as HP Select.On -> HH.ul (Setters.setContainerProps []) $ 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 |
There we go! Try toggling the menu on and off, using your arrow, enter, and escape keys, and so on. It works!
...almost. Alas, we aren't doing anything when the user makes a selection. Select
is attempting to notify us that a selection occurred, but we never provided a handler. Let's fix that now.
Handling messages from Select¶
When you add a new child component you invariably need to add a handler for its Message
type. What should the parent do when something important has occurred in the child? To handle messages, add a new constructor to your query algebra that takes the child's Message
type as an argument:
1 2 3 | data Query a = NoOp a | HandleSelect Select.Message a |
Ah -- this won't compile!
1 2 3 4 5 6 7 8 9 | Error found in module Component: Could not match kind (Type -> Type) -> Type -> Type with kind Type in type constructor Query |
This looks similar to the type error we got when we tried to just use Select.Query
in a type synonym. We need to provide a Type
to HandleSelect
, but Select.Message
is still awaiting 2 arguments, the first of which is itself awaiting an argument! Let's go look at the module documentation for Select.Message
.
1 | data Message o item |
We've seen both of these arguments before in the component's query type, so we should fill them in with the same values. o
is our parent component query type, and item
is a String
:
1 2 3 | data Query a = NoOp a | HandleSelect (Select.Message Query String) a |
As soon as you save and rebuild you'll see another compiler error!
1 2 3 4 5 6 7 8 | Error found in module Component A case expression could not be determined to cover all inputs. The following additional cases are required to cover all inputs: (HandleSelect _ _) in value declaration component |
This time it's because we've added a new query, but we never updated our 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
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:
1 2 3 4 5 | data Message o item = Searched String | Selected item | VisibilityChanged Visibility | Emit (o Unit) |
Let's stub out each of these cases and then decide what to do with them:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | eval :: Query ~> H.ParentDSL State Query (ChildQuery eff) ChildSlot Message m eval = case _ of NoOp next -> pure next HandleSelect message next -> case message of Select.Searched string -> pure next Select.Selected item -> pure next Select.VisibilityChanged vis -> pure next Select.Emit query -> pure next |
Let's take these case-by-case.
What should we do when the user has searched something on our dropdown? This is just a simple list of items, so we'll simply ignore their search. We can leave this as pure next
.
What should we do when the user has selected an item? Ah! This is more interesting. We want to set their item as the currently-selected one, and then we want to remove it from the available items list. Once we've removed it from the available items, we'll update Select
with its new items to display and we'll toggle it off.
We can use difference
from Data.Array
to filter out the selected item from the overall list of items. This is a common pattern in Select
: the parent holds the immutable list of all possible items, and Select
receives some subset of those items at each render. You might use the user's search to filter out items in a typeahead, for example, or only load 50 results at a time into a dropdown.
1 2 3 4 5 6 7 | import Data.Array (difference) Select.Selected item -> do st <- H.get _ <- H.query unit $ Select.setVisibility Select.Off _ <- H.query unit $ Select.replaceItems $ difference st.availableItems [ item ] H.modify _ { selectedItem = Just item } |
What should we do when the dropdown's visibility has changed? This can often be useful to run validation, but for our dropdown, we don't care what its visibility is. We can leave this as pure next
.
Finally, what should we do when the child component raises its Emit
message? What does this even mean? Emit
exists so you can embed your own queries into Select
and extend its behavior. Since the message contains one of your own queries, all you have to do is evaluate it: you can call eval
recursively to run your query.
You can think of Emit
as notifying you that the query you embedded is ready to run.
1 2 3 | Select.Emit query -> do eval query pure next |
Nice and simple! While you may write all kinds of logic for the other messages raised by Select
, you'll always write this same code for the Emit
message.
Conclusion¶
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 or on the PureScript user forum. 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.
Source Code¶
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.
Full source code for the tutorial
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 | module Component where import Prelude import Effect.Aff.Class (class MonadAff) import Data.Array (difference, mapWithIndex) import Data.Maybe (Maybe(..), fromMaybe) import Halogen as H import Halogen.HTML as HH import Halogen.HTML.Events as HE import Halogen.HTML.Properties as HP import Select as Select import Select.Setters as Setters data Query a = HandleSelect (Select.Message Query String) a type State = { isOpen :: Boolean , selectedItem :: Maybe String , availableItems :: Array String } type Input = Unit type Message = Void type ChildSlot = Unit type ChildQuery = Select.Query Query String component :: ∀ m. MonadAff m => H.Component HH.HTML Query Input Message m component = H.parentComponent { initialState , render , eval , receiver: const Nothing } where initialState :: Input -> State initialState = const { isOpen: false , selectedItem: Nothing , availableItems: [ "Item One" , "Item Two" , "Item Three" ] } render :: State -> H.ParentHTML Query ChildQuery ChildSlot m render parentState = HH.div_ [ HH.h1_ [ HH.text "Dropdown" ] , HH.slot unit Select.component selectInput (HE.input HandleSelect) ] where selectInput :: Select.Input Query String selectInput = { inputType: Select.Toggle , items: parentState.availableItems , initialSearch: Nothing , debounceTime: Nothing , render: dropdown } dropdown :: Select.State String -> Select.ComponentHTML Query String dropdown childState = HH.div_ [ HH.button (Setters.setToggleProps []) [ HH.text $ fromMaybe "Click me to view some items" parentState.selectedItem ] , case childState.visibility of Select.Off -> HH.text "" Select.On -> HH.ul (Setters.setContainerProps []) $ 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 Query ChildQuery ChildSlot Message m eval = case _ of HandleSelect message next -> case message of Select.Searched string -> pure next Select.Selected item -> do st <- H.get _ <- H.query unit $ Select.setVisibility Select.Off _ <- H.query unit $ Select.replaceItems $ difference st.availableItems [ item ] H.modify _ { selectedItem = Just item } pure next Select.VisibilityChanged vis -> pure next Select.Emit query -> do eval query pure next |