This is a demonstration of a basic navigation system created with Elm. It's useful because it shows how a parent module can share information with its nested child module, and also how sibling modules can communicate back and forth. This essential system can be used as the basis for building all kinds of menu bars or navigation systems for websites or apps. It's based on the Elm Architecture using techniques no more advanced than Example #4: A Fancier List of Counters. If you're new to Elm, I recommend that you carefully work through and understand the first 4 Elm Architecture examples before approaching the code in this repository.
Run Main.elm and you'll see 3 buttons, each labeled with the name
of a different color. When the mouse moves over a button, the button
label changes to red. When the mouse moves out, the label changes back to
white.
The mouse pointer also changes to a hand icon when it's over a button.
When you click on a button, the button color darkens and the big square below the list of buttons changes to the same color as the button label.
The selected button will stay selected until you click another button.
In most other programming languages, building a navigation menu bar like this usually involves a lot of tricky state management and is highly bug-prone. But, as you'll see ahead, thanks to Elm's immutable data structures and pristine module encapsulation, button states practically manage themselves.
Each button is made from a module called NavButton. The buttons are nested
inside a parent module called NavBar, which creates the list of buttons.
The main program is a module called ColorChanger, which imports the
NavBar and ColorBox. The ColorBox is just the big square which
changes color when any of the buttons are clicked.
ColorChanger is the top-level module. Its job is to find out from
the NavBar what the currently selected button is, and then change
the color of the ColorBox to match that color.
It's the job of the NavBar to track which button has been selected,
and to tell that button to switch to its selected state. It's this
interesting back-and-forth communication between parent and child
modules which is very useful pattern to know for building menu systems
for all kinds of applications.
The NavButton is the lowest level building block. Its update function
is a very simple state machine that describes all the possible actions
that a button can have.
type Action
= Over
| Out
| Select
| Deselect
update : Action -> Model -> Model
update action model =
case action of
Over ->
{ model | textColor = "red" }
Out ->
{ model | textColor = "white" }
Select ->
{ model
| backgroundColor = "grey"
, selected = True
}
Deselect ->
{ model
| backgroundColor = "darkGrey"
, selected = False
}
If you've read through the Elm Architecture, you'll immediately recognize this pattern.
The button's Over and Out hover states can be handled internally
by the NavButton module itself. However (and very importantly!), we want the Select and Deselect
actions to be handled by the parent NavBar module.
How can we do this? By creating a Context which allows the
selection actions to be handled by the parent module. Here's the
Context in the NavButton that sets this up.
type alias Context =
{ actions : Signal.Address Action
, select : Signal.Address ()
}actions will be directed internally to this NavButton module, and
select will be directed to the external parent module. We don't need to know
what the parent module is - this will work no matter what other module
NavButton is imported into.
Here's the code in the NavButton's view that uses the Context to set this up.
view : Context -> Model -> Html
view context model =
li
[ navButtonStyle model
, onMouseEnter context.actions Over
, onMouseLeave context.actions Out
, onClick context.select ()
]
[ text model.content ]You can see that the onClick event is directed to context.select.
Now the next step is to set up the parent module to handle the
context.select action.
The NavBar module imports the NavButton module. Its Model
record is very simple: a list of buttons, and selectedButton
property that is used to track the currently selected button.
type alias Model =
{ buttons : List NavButton.Model
, selectedButton : String
}
model : Model
model =
{ buttons = []
, selectedButton = ""
}It has an initialize function that takes a list of button label
names, and uses them to create a list of buttons. If you give it 10
names, you'll end up with 10 buttons. (The example in this repository
just uses a list of three names: "Blue", "Pink" and "Orange")
initialize : List String -> Model
initialize names =
let createButtons names =
List.map (\button -> NavButton.initialize button) names
in
{ model | buttons = createButtons names }The structure of the NavBar module is otherwise very similar to the
CounterList module in Example 4 of the Elm Architecture. The
viewButton function uses the NavButton.Context to determine which
modules should handle which actions.
viewButton : Signal.Address Action -> ID -> NavButton.Model -> Html
viewButton address id model =
let context =
NavButton.Context
(Signal.forwardTo address (Hover id))
(Signal.forwardTo address (always (Select model)))
in
NavButton.view context modelThe Hover action (which sets the buttons Over and Out states)
will be handled by the child NavButton module. But the Select action
will be handled by the NavBar module itself. We want the NavBar module to
handle it so that it can track which button has currently been
selected. Here's the Select action code from the NavBar's update
function that does this.
Select selectedButtonModel ->
let
-- `updateButton` checks whether a button has the same
-- content string as the selected button. If it does, it
-- updates the model and sets its action to `Select`.
-- If it doesn't, it sets the button's action to `Deselect`
-- (See the `NavButton` module for details on how this works.)
updateButton buttonModel =
if buttonModel.content == selectedButtonModel.content
then
NavButton.update NavButton.Select buttonModel
else
NavButton.update NavButton.Deselect buttonModel
in
-- Set the name of the currently clicked-on button as the
-- model's `selectedButton` and update the buttons to reflect
-- their selection state.
{ model
| selectedButton = selectedButtonModel.content
, buttons = List.map updateButton model.buttons
}
Here's how this works:
Select takes the currently clicked NavButton model as an argument,
and sets the NavBar's selectedButton property to the same value as
the selected button's content. (This will be either "Blue", "Pink" or
"Orange" in this example.) The NavBar's buttons list is then
updated by checking whether any button in the list has the same
content as the selected button that was passed into the Select
action. If it does, then that button's update function is called with
the value Select. If it doesn't, the button's update function is
called with the value Deselect. Select and Deselect are actions
in the NavButton module which will change the button's background
color.
This is how the navigation bar maintains the correct button selection state, but how does it actually change the color of the big square in the main application?
The ColorBox is the big square at the bottom of the navigation bar
that changes color when the buttons are pressed. It has a very simple
model:
model =
{ color = "red"
, width = 200
, height = 200
}It also has a function called changeColor that returns a new model
with a different color.
changeColor : String -> Model
changeColor newColor =
{ model | color = newColor }Both this ColorBox module and the NavBar module are imported into
a new parent module called ColorChanger which is actually the main
application that puts everything together. The ColorChanger model is a record which initializes a new
NavBar with a list of the button names you want to use. It also has a ColorBox model.
type alias Model =
{ navigationBar : NavBar.Model
, colorBox : ColorBox.Model
}
model : Model
model =
{ navigationBar = NavBar.initialize ["Blue", "Pink", "Orange"]
, colorBox = ColorBox.model
}Its view is typical of any ordinary Elm Architecture view pattern.
It directs the NavBar to handle its own actions, and simply displays
the ColorBox (which has no actions.)
view : Signal.Address Action -> Model -> Html
view address model =
div []
[ NavBar.view (Signal.forwardTo address UpdateNavigation) model.navigationBar
, ColorBox.view model.colorBox
]The ColorChanger's update function first updates the navigation
bar with a newNavBar. This is important so that the correct, current
button states are properly displayed. Next, it uses the ColorBox's changeColor function
to get a new colorBox which is the same color as whatever the newNavBar's current
selectedButton is.
type Action = UpdateNavigation NavBar.Action
update : Action -> Model -> Model
update action model =
case action of
UpdateNavigation action ->
let
-- Get a new updated version of the navigation bar
newNavBar = NavBar.update action model.navigationBar
-- Get the color of the NavBar's selected button
getColor selectedButton =
if selectedButton /= ""
then
ColorBox.changeColor selectedButton
else
model.colorBox
in
-- Update the model with the new navigation bar and set the
-- `colorBox` to the color of the new navigation bar's
-- `selectedButton`
{ model
| navigationBar = newNavBar
, colorBox = getColor newNavBar.selectedButton
}And that's it!
A few reasons:
- The buttons manage their internal state, and don't need to know anything about the state of the other buttons in the navigation bar. They don't need to know whether they've been selected or deselected.
- The navigation bar just decides which buttons should be selected or deselected, and the buttons figure out for themselves how they should behave in those states.
- The color box doesn't need to know anything about the state of the buttons - it just cares about whatever the navigation bar's currently selected button is.
Building navigation bar menu systems like this tends to be one of the most tedious and usually bug-prone aspects of application design. That's because of all the tricky state management you have to be careful of. But thanks to Elm and the Elm Architecture, modules can manage their own internal states and remain completely encapsulated from the rest of the system. And, best of all: you can scale this same basic system to any size without having to do any more work, and be guaranteed the same bug-free performance. Yay, Elm!


