Multiple Modals
This intermediate guide shows how to create a multiple modal form.
The final widget looks like:
See the Pen CanJS 5.0 - Multiple Modals - Final by Bitovi (@bitovi) on CodePen.
The following sections are broken down the following parts:
The problem - A description of what the section is trying to accomplish.
What you need to know - Information about CanJS that is useful for solving the problem.
How to verify it works - How to make sure the solution works if it’s not obvious.
The solution - The solution to the problem.
Setup
The problem
In this section, we will fork this CodePen that contains some starting code that we will modify to use modals instead of adding each form directly in the page.
What you need to know
The CodePen creates and several basic components:
<occupation-questions>
- A form that asks the sorts of things the user does.<diva-questions>
- A form that asks for expenses for divas.<programmer-questions>
- A form that asks for a programmer's programming language.<income-questions>
- A form that asks how the user gets paid.<my-app>
- The main application component. It uses all of the above components to update its value.
<my-app>
is mounted in the HTML
tab as follows:
<my-app></my-app>
The solution
START THIS TUTORIAL BY CLONING THE FOLLOWING CODEPEN:
Click the
EDIT ON CODEPEN
button. The CodePen will open in a new window. In that new window, clickFORK
.
See the Pen CanJS 5.0 - Multiple Modals - Setup by Bitovi (@bitovi) on CodePen.
This CodePen:
- Loads all of CanJS’s packages. Each package is available as a named export. For example can-component
is available as
import {Component} from "can"
.
Create a simple modal
The problem
In this section, we will:
- Create a simple
<my-modal>
custom element that will put its "light DOM" within a modal window. - Show the
<diva-questions>
component whenisDiva
is set to true.
What you need to know
Use
{{# if(value) }} HTML {{/ if }}
to showHTML
whenvalue
is true.Content between custom element tags like:
<custom-element>SOME CONTENT</custom-element>
Is available to be rendered with the
<content>
element within the custom element’sview
. The following would putSOME CONTENT
within an<h1>
element:Component.extend({ tag: "custom-element", view: `<h1><content></content></h1>` })
How to verify it works
If you click the isDiva
radio input, a modal window with the <diva-questions>
form should appear.
The solution
Update the JS
tab to:
import { Component, value, stacheConverters, stache } from "//unpkg.com/can@5/ecosystem.mjs";
stache.addConverter(stacheConverters);
const OccupationQuestions = Component.extend({
tag: "occupation-questions",
view: `
<h3>Occupation</h3>
<div class='content'>
<p>Are you a diva?
<input type="radio" checked:bind="equal(isDiva, true)"/> yes
<input type="radio" checked:bind="equal(isDiva, false)"/> no
</p>
<p>Do you program?
<input type="radio" checked:bind="equal(isProgrammer, true)"/> yes
<input type="radio" checked:bind="equal(isProgrammer, false)"/> no
</p>
<p><button on:click="next()">Next</button></p>
</div>
`,
ViewModel: {
isDiva: "boolean",
isProgrammer: "boolean"
}
});
const DivaQuestions = Component.extend({
tag: "diva-questions",
view: `
<h3>Diva Questions</h3>
<div class='content'>
<p>Check all expenses that apply:</p>
<p><input type="checkbox"
checked:bind="boolean-to-inList('Swagger', divaExpenses)"> Swagger
</p>
<p><input type="checkbox"
checked:bind="boolean-to-inList('Fame', divaExpenses)"> Fame
</p>
<p><button on:click="next()">Next</button></p>
</div>
`,
ViewModel: {
divaExpenses: "any"
}
});
const ProgrammerQuestions = Component.extend({
tag: "programmer-questions",
view: `
<h3>Programmer Questions</h3>
<div class='content'>
<p>What is your favorite language?</p>
<p>
<select value:to="programmingLanguage">
<option>C</option>
<option>C++</option>
<option>Java</option>
<option>JavaScript</option>
</select>
</p>
<p><button on:click="next()">Next</button></p>
</div>
`,
ViewModel: {
programmingLanguage: "string"
}
});
const IncomeQuestions = Component.extend({
tag: "income-questions",
view: `
<h3>Income</h3>
<div class='content'>
<p>What do you get paid in?</p>
<p>
<select value:bind="string-to-any(paymentType)">
<option value="undefined">Select a type</option>
<option>Peanuts</option>
<option>Bread</option>
<option>Tamales</option>
<option>Cheddar</option>
<option>Dough</option>
</select>
</p>
<p><button on:click="next()">Finish</button></p>
</div>
`,
ViewModel: {
paymentType: "string"
}
});
Component.extend({
tag: "my-modals",
view: `
<div class='background'></div>
<div class='modal-container'>
<content>Supply some content</content>
</div>
`
});
Component.extend({
tag: "my-app",
view: `
<occupation-questions isDiva:bind="isDiva" isProgrammer:bind="isProgrammer"/>
<p>isDiva: {{ isDiva }}</p>
<p>isProgrammer: {{ isProgrammer }}</p>
{{# if(isDiva) }}
<my-modals> <diva-questions divaExpenses:bind="divaExpenses"/> </my-modals>
{{/ if }}
<p>diva expenses: {{ divaExpenses.join(', ') }}</p>
<programmer-questions programmingLanguage:bind="programmingLanguage"/>
<p>programmingLanguage: {{ programmingLanguage }}</p>
<income-questions paymentType:bind="paymentType"/>
<p>paymentType: {{ paymentType }}</p>
`,
ViewModel: {
// Stateful properties
isDiva: { type: "boolean", default: false },
divaExpenses: { Default: Array },
isProgrammer: { type: "boolean", default: false },
programmingLanguage: "string",
paymentType: "string",
// Derived properties
// Methods
}
});
Pass a component instance
The problem
In this section, we are matching the same behavior as the
previous example. However, we are going to change the <my-modals>
component to take a component instance to render
in a modal instead of "light DOM".
What you need to know
Use
{default(){ /* ... */ }}
to create a default value for a property:ViewModel: { dueDate: { default(){ return new Date(); } } }
Component instances can be created like:
let component = new ProgrammerQuestions({ viewModel: { programmingLanguage: "JS" } });
This is roughly equivalent to:
<programmer-questions programmingLanguage:from="'JS'"/>
Use can-value to setup a two-way binding from one component to another:
ViewModel: { programmerQuestions: { default(){ return let component = new ProgrammerQuestions({ viewModel: { programmingLanguage: value.bind(this, "programmingLanguage") } }); } } }
This is roughly equivalent to:
<programmer-questions programmingLanguage:bind="programmingLanguage"/>
Render a component instance with
{{component}}
.
The solution
Update the JS
tab to:
import { Component, value, stacheConverters, stache } from "//unpkg.com/can@5/ecosystem.mjs";
stache.addConverter(stacheConverters);
const OccupationQuestions = Component.extend({
tag: "occupation-questions",
view: `
<h3>Occupation</h3>
<div class='content'>
<p>Are you a diva?
<input type="radio" checked:bind="equal(isDiva, true)"/> yes
<input type="radio" checked:bind="equal(isDiva, false)"/> no
</p>
<p>Do you program?
<input type="radio" checked:bind="equal(isProgrammer, true)"/> yes
<input type="radio" checked:bind="equal(isProgrammer, false)"/> no
</p>
<p><button on:click="next()">Next</button></p>
</div>
`,
ViewModel: {
isDiva: "boolean",
isProgrammer: "boolean"
}
});
const DivaQuestions = Component.extend({
tag: "diva-questions",
view: `
<h3>Diva Questions</h3>
<div class='content'>
<p>Check all expenses that apply:</p>
<p><input type="checkbox"
checked:bind="boolean-to-inList('Swagger', divaExpenses)"> Swagger
</p>
<p><input type="checkbox"
checked:bind="boolean-to-inList('Fame', divaExpenses)"> Fame
</p>
<p><button on:click="next()">Next</button></p>
</div>
`,
ViewModel: {
divaExpenses: "any"
}
});
const ProgrammerQuestions = Component.extend({
tag: "programmer-questions",
view: `
<h3>Programmer Questions</h3>
<div class='content'>
<p>What is your favorite language?</p>
<p>
<select value:to="programmingLanguage">
<option>C</option>
<option>C++</option>
<option>Java</option>
<option>JavaScript</option>
</select>
</p>
<p><button on:click="next()">Next</button></p>
</div>
`,
ViewModel: {
programmingLanguage: "string"
}
});
const IncomeQuestions = Component.extend({
tag: "income-questions",
view: `
<h3>Income</h3>
<div class='content'>
<p>What do you get paid in?</p>
<p>
<select value:bind="string-to-any(paymentType)">
<option value="undefined">Select a type</option>
<option>Peanuts</option>
<option>Bread</option>
<option>Tamales</option>
<option>Cheddar</option>
<option>Dough</option>
</select>
</p>
<p><button on:click="next()">Finish</button></p>
</div>
`,
ViewModel: {
paymentType: "string"
}
});
Component.extend({
tag: "my-modals",
view: `
<div class='background'></div>
<div class='modal-container'>
{{ component }}
</div>
`
});
Component.extend({
tag: "my-app",
view: `
<occupation-questions isDiva:bind="isDiva" isProgrammer:bind="isProgrammer"/>
<p>isDiva: {{ isDiva }}</p>
<p>isProgrammer: {{ isProgrammer }}</p>
{{# if(isDiva) }}
<my-modals component:from="divaQuestions"></my-modals>
{{/ if }}
<p>diva expenses: {{ divaExpenses.join(', ') }}</p>
<programmer-questions programmingLanguage:bind="programmingLanguage"/>
<p>programmingLanguage: {{ programmingLanguage }}</p>
<income-questions paymentType:bind="paymentType"/>
<p>paymentType: {{ paymentType }}</p>
`,
ViewModel: {
// Stateful properties
isDiva: { type: "boolean", default: false },
divaExpenses: { Default: Array },
isProgrammer: { type: "boolean", default: false },
programmingLanguage: "string",
paymentType: "string",
divaQuestions: {
default() {
return new DivaQuestions({
viewModel: {
divaExpenses: value.bind(this, "divaExpenses")
}
});
}
},
// Derived properties
// Methods
}
});
Show multiple modals in the window
The problem
In this section, we will:
- Show all form components within a modal box.
- Show the
<diva-questions>
and<programmer-questions>
modals only if their respective questions (isDiva
andisProgrammer
) checkboxes are selected. - Remove all the form components from being rendered in the main page content area.
We will do this by:
- Changing
<my-modals>
to:- take an array of component instances.
- position the component instances within
<div class='modal-container'>
elements 20 pixels apart.
- Changing
<my-app>
to:- create instances for the
OccupationQuestions
,ProgrammerQuestions
, andIncomeQuestions
components. - create a
visibleQuestions
array that contains only the instances that should be presented to the user.
- create instances for the
What you need to know
Use ES5 getters to transform stateful properties on a ViewModel to new values. For example, the following returns
true
if someone is a diva and a programmer:ViewModel: { isDiva: "boolean", isProgrammer: "boolean", get isDivaAndProgrammer(){ return this.isDiva && this.isProgrammer; } }
This can be used to derive the
visibleQuestions
array.
The solution
Update the JS
tab to:
import { Component, value, stacheConverters, stache } from "//unpkg.com/can@5/ecosystem.mjs";
stache.addConverter(stacheConverters);
const OccupationQuestions = Component.extend({
tag: "occupation-questions",
view: `
<h3>Occupation</h3>
<div class='content'>
<p>Are you a diva?
<input type="radio" checked:bind="equal(isDiva, true)"/> yes
<input type="radio" checked:bind="equal(isDiva, false)"/> no
</p>
<p>Do you program?
<input type="radio" checked:bind="equal(isProgrammer, true)"/> yes
<input type="radio" checked:bind="equal(isProgrammer, false)"/> no
</p>
<p><button on:click="next()">Next</button></p>
</div>
`,
ViewModel: {
isDiva: "boolean",
isProgrammer: "boolean"
}
});
const DivaQuestions = Component.extend({
tag: "diva-questions",
view: `
<h3>Diva Questions</h3>
<div class='content'>
<p>Check all expenses that apply:</p>
<p><input type="checkbox"
checked:bind="boolean-to-inList('Swagger', divaExpenses)"> Swagger
</p>
<p><input type="checkbox"
checked:bind="boolean-to-inList('Fame', divaExpenses)"> Fame
</p>
<p><button on:click="next()">Next</button></p>
</div>
`,
ViewModel: {
divaExpenses: "any"
}
});
const ProgrammerQuestions = Component.extend({
tag: "programmer-questions",
view: `
<h3>Programmer Questions</h3>
<div class='content'>
<p>What is your favorite language?</p>
<p>
<select value:to="programmingLanguage">
<option>C</option>
<option>C++</option>
<option>Java</option>
<option>JavaScript</option>
</select>
</p>
<p><button on:click="next()">Next</button></p>
</div>
`,
ViewModel: {
programmingLanguage: "string"
}
});
const IncomeQuestions = Component.extend({
tag: "income-questions",
view: `
<h3>Income</h3>
<div class='content'>
<p>What do you get paid in?</p>
<p>
<select value:bind="string-to-any(paymentType)">
<option value="undefined">Select a type</option>
<option>Peanuts</option>
<option>Bread</option>
<option>Tamales</option>
<option>Cheddar</option>
<option>Dough</option>
</select>
</p>
<p><button on:click="next()">Finish</button></p>
</div>
`,
ViewModel: {
paymentType: "string"
}
});
Component.extend({
tag: "my-modals",
view: `
{{# for(componentData of componentsToShow) }}
{{# if(componentData.last) }}
<div class='background'></div>
{{/ if }}
<div class='modal-container'
style="margin-top: {{ componentData.position }}px; margin-left: {{ componentData.position }}px">
{{ componentData.component }}
</div>
{{/ for }}
`,
ViewModel: {
get componentsToShow() {
var distance = 20;
var count = this.components.length;
var start = -150 - (distance / 2) * (count - 1);
return this.components.map(function(component, i) {
return {
position: start + i * distance,
component: component,
last: i === count - 1
}
});
}
}
});
Component.extend({
tag: "my-app",
view: `
<my-modals components:from="visibleQuestions"></my-modals>
<p>isDiva: {{ isDiva }}</p>
<p>isProgrammer: {{ isProgrammer }}</p>
<p>diva expenses: {{ divaExpenses.join(', ') }}</p>
<p>programmingLanguage: {{ programmingLanguage }}</p>
<p>paymentType: {{ paymentType }}</p>
`,
ViewModel: {
// Stateful properties
isDiva: { type: "boolean", default: false },
divaExpenses: { Default: Array },
isProgrammer: { type: "boolean", default: false },
programmingLanguage: "string",
paymentType: "string",
occupationQuestions: {
default() {
return new OccupationQuestions({
viewModel: {
isDiva: value.bind(this, "isDiva"),
isProgrammer: value.bind(this, "isProgrammer")
}
});
}
},
divaQuestions: {
default() {
return new DivaQuestions({
viewModel: {
divaExpenses: value.bind(this, "divaExpenses")
}
});
}
},
programmerQuestions: {
default() {
return new ProgrammerQuestions({
viewModel: {
programmingLanguage: value.bind(this, "programmingLanguage")
}
});
}
},
incomeQuestions: {
default() {
return new IncomeQuestions({
viewModel: {
paymentType: value.bind(this, "paymentType")
}
});
}
},
// Derived properties
get allQuestions() {
var forms = [this.occupationQuestions];
if (this.isDiva) {
forms.push(this.divaQuestions)
}
if (this.isProgrammer) {
forms.push(this.programmerQuestions)
}
forms.push(this.incomeQuestions);
return forms;
},
get visibleQuestions() {
return this.allQuestions.slice(0).reverse();
},
// Methods
}
});
Next should move to the next window
The problem
In this section, we will make it so when someone clicks the Next
button in a modal, the next modal window will be displayed.
What you need to know
We can use a index of which question we have answered to know which
questions should be returned by visibleQuestions
.
The following creates a counting index and a method that increments it:
ViewModel: {
questionIndex: { default: 0 },
next(){
this.questionIndex++;
}
}
To pass the next
function to a component, you must make sure that the right this
is preserved. You can do that with function.bind
like:
new ProgrammerQuestions({
viewModel: {
programmingLanguage: value.bind(this, "programmingLanguage"),
next: this.next.bind(this)
}
});
The solution
import { Component, value, stacheConverters, stache } from "//unpkg.com/can@5/ecosystem.mjs";
stache.addConverter(stacheConverters);
const OccupationQuestions = Component.extend({
tag: "occupation-questions",
view: `
<h3>Occupation</h3>
<div class='content'>
<p>Are you a diva?
<input type="radio" checked:bind="equal(isDiva, true)"/> yes
<input type="radio" checked:bind="equal(isDiva, false)"/> no
</p>
<p>Do you program?
<input type="radio" checked:bind="equal(isProgrammer, true)"/> yes
<input type="radio" checked:bind="equal(isProgrammer, false)"/> no
</p>
<p><button on:click="next()">Next</button></p>
</div>
`,
ViewModel: {
isDiva: "boolean",
isProgrammer: "boolean"
}
});
const DivaQuestions = Component.extend({
tag: "diva-questions",
view: `
<h3>Diva Questions</h3>
<div class='content'>
<p>Check all expenses that apply:</p>
<p><input type="checkbox"
checked:bind="boolean-to-inList('Swagger', divaExpenses)"> Swagger
</p>
<p><input type="checkbox"
checked:bind="boolean-to-inList('Fame', divaExpenses)"> Fame
</p>
<p><button on:click="next()">Next</button></p>
</div>
`,
ViewModel: {
divaExpenses: "any"
}
});
const ProgrammerQuestions = Component.extend({
tag: "programmer-questions",
view: `
<h3>Programmer Questions</h3>
<div class='content'>
<p>What is your favorite language?</p>
<p>
<select value:to="programmingLanguage">
<option>C</option>
<option>C++</option>
<option>Java</option>
<option>JavaScript</option>
</select>
</p>
<p><button on:click="next()">Next</button></p>
</div>
`,
ViewModel: {
programmingLanguage: "string"
}
});
const IncomeQuestions = Component.extend({
tag: "income-questions",
view: `
<h3>Income</h3>
<div class='content'>
<p>What do you get paid in?</p>
<p>
<select value:bind="string-to-any(paymentType)">
<option value="undefined">Select a type</option>
<option>Peanuts</option>
<option>Bread</option>
<option>Tamales</option>
<option>Cheddar</option>
<option>Dough</option>
</select>
</p>
<p><button on:click="next()">Finish</button></p>
</div>
`,
ViewModel: {
paymentType: "string"
}
});
Component.extend({
tag: "my-modals",
view: `
{{# for(componentData of componentsToShow) }}
{{# if(componentData.last) }}
<div class='background'></div>
{{/ if }}
<div class='modal-container'
style="margin-top: {{ componentData.position }}px; margin-left: {{ componentData.position }}px">
{{ componentData.component }}
</div>
{{/ for }}
`,
ViewModel: {
get componentsToShow() {
var distance = 20;
var count = this.components.length;
var start = -150 - (distance / 2) * (count - 1);
return this.components.map(function(component, i) {
return {
position: start + i * distance,
component: component,
last: i === count - 1
}
});
}
}
});
Component.extend({
tag: "my-app",
view: `
<my-modals components:from="visibleQuestions"></my-modals>
<p>isDiva: {{ isDiva }}</p>
<p>isProgrammer: {{ isProgrammer }}</p>
<p>diva expenses: {{ divaExpenses.join(', ') }}</p>
<p>programmingLanguage: {{ programmingLanguage }}</p>
<p>paymentType: {{ paymentType }}</p>
`,
ViewModel: {
// Stateful properties
isDiva: { type: "boolean", default: false },
divaExpenses: { Default: Array },
isProgrammer: { type: "boolean", default: false },
programmingLanguage: "string",
paymentType: "string",
occupationQuestions: {
default() {
return new OccupationQuestions({
viewModel: {
isDiva: value.bind(this, "isDiva"),
isProgrammer: value.bind(this, "isProgrammer"),
next: this.next.bind(this)
}
});
}
},
divaQuestions: {
default() {
return new DivaQuestions({
viewModel: {
divaExpenses: value.bind(this, "divaExpenses"),
next: this.next.bind(this)
}
});
}
},
programmerQuestions: {
default() {
return new ProgrammerQuestions({
viewModel: {
programmingLanguage: value.bind(this, "programmingLanguage"),
next: this.next.bind(this)
}
});
}
},
incomeQuestions: {
default() {
return new IncomeQuestions({
viewModel: {
paymentType: value.bind(this, "paymentType"),
next: this.next.bind(this)
}
});
}
},
questionIndex: { default: 0 },
// Derived properties
get allQuestions() {
var forms = [this.occupationQuestions];
if (this.isDiva) {
forms.push(this.divaQuestions)
}
if (this.isProgrammer) {
forms.push(this.programmerQuestions)
}
forms.push(this.incomeQuestions);
return forms;
},
get visibleQuestions() {
return this.allQuestions.slice(this.questionIndex).reverse();
},
// Methods
next() {
this.questionIndex++;
}
}
});
Result
When complete, you should have a working multiple modal form like the following CodePen:
See the Pen CanJS 5.0 - Multiple Modals - Final by Bitovi (@bitovi) on CodePen.