diff --git a/apps/tutorial/public/docs/8-form-data-controlled/1-text-input/answer.gjs b/apps/tutorial/public/docs/8-form-data-controlled/1-text-input/answer.gjs new file mode 100644 index 000000000..bb89a584f --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/1-text-input/answer.gjs @@ -0,0 +1,41 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; + +class ControlledInput extends Component { + handleInput = (event) => { + let input = event.target; + let value = input.value; + + this.args.onChange(value); + } + + +} + +class State { + @tracked value; + handleChange = (newValue) => this.value = newValue; +} +const createState = () => new State(); + + + diff --git a/apps/tutorial/public/docs/8-form-data-controlled/1-text-input/prompt.gjs b/apps/tutorial/public/docs/8-form-data-controlled/1-text-input/prompt.gjs new file mode 100644 index 000000000..1b38c7c9a --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/1-text-input/prompt.gjs @@ -0,0 +1,36 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; + +class ControlledInput extends Component { + +} + +// Some state is defined, and then created in the template +class State { + @tracked value; + handleChange = (newValue) => this.value = newValue; +} +const createState = () => new State(); + + + diff --git a/apps/tutorial/public/docs/8-form-data-controlled/1-text-input/prose.md b/apps/tutorial/public/docs/8-form-data-controlled/1-text-input/prose.md new file mode 100644 index 000000000..4b90f2366 --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/1-text-input/prose.md @@ -0,0 +1,32 @@ +An _uncontrolled_ input is what we've used so far, where the inputs themselves manage their own internal state. We do not control that state, and so they are _uncontrolled_. + +A _controlled_ input is controlled by the invoking component. + +There are two parts to controlling a component: managing the value, and responding to an event on the input (which usually sets that managed value). + + +Managing the value is the same as we've seen before with setting an input's initial value: +```hbs + +``` + +The second part, responding to an event on the input, usually unwraps the event and passes the current value to the calling component: +```gjs +class Demo extends Component { + handleInput = (event) => { + this.args.onChange(event.target.value); + } + + +} +``` + +Because, with a text input, we expect a string to be passed to an `onChange` handler, we have nothing more to do. + +

+ Change the input within the ControlledInput component + to be controlled. +

+ diff --git a/apps/tutorial/public/docs/8-form-data-controlled/2-checkbox/answer.gjs b/apps/tutorial/public/docs/8-form-data-controlled/2-checkbox/answer.gjs new file mode 100644 index 000000000..b7bf7e996 --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/2-checkbox/answer.gjs @@ -0,0 +1,46 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; + +class ControlledInput extends Component { + handleChange = (event) => { + let input = event.target; + let value = Boolean(input.checked); + + this.args.onChange(value); + } + + +} + +// Below is only setup for the tutorial chapter +// and not exactly relevent to the topic +class State { + @tracked value; + handleChange = (newValue) => this.value = newValue; +} +const createState = () => new State(); + + + diff --git a/apps/tutorial/public/docs/8-form-data-controlled/2-checkbox/prompt.gjs b/apps/tutorial/public/docs/8-form-data-controlled/2-checkbox/prompt.gjs new file mode 100644 index 000000000..743862101 --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/2-checkbox/prompt.gjs @@ -0,0 +1,36 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; + +class ControlledInput extends Component { + +} + +// Below is only setup for the tutorial chapter +// and not exactly relevent to the topic +class State { + @tracked value; + handleChange = (newValue) => this.value = newValue; +} +const createState = () => new State(); + + + diff --git a/apps/tutorial/public/docs/8-form-data-controlled/2-checkbox/prose.md b/apps/tutorial/public/docs/8-form-data-controlled/2-checkbox/prose.md new file mode 100644 index 000000000..dff6f0432 --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/2-checkbox/prose.md @@ -0,0 +1,41 @@ +Just like the controlled input, the _controlled checkbox_ is roughly the same approach, but with a different property and event binding combination. + +Instead of setting value, we'll set `checked`. +```hbs + +``` + +And for the event binding, we'll use the `change` event. +```gjs +class Demo extends Component { + handleChange = (event) => { + let value = Boolean(event.target.checked); + this.args.onChange(value); + } + + +} +``` + +Checkboxes have a default `value` of `on` (as a string), which may not be a desired value to pass to your `onChange` handler. In this example, we can convert the `checked` property to a boolean. + +

+ Change the checkbox within the ControlledInput component + to be controlled. +

+ + +### References + +- [Checkbox Inputs at MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox) + +API Docs: +- [let](https://api.emberjs.com/ember/5.8/classes/Ember.Templates.helpers/methods/let?anchor=let) +- [on](https://api.emberjs.com/ember/5.8/classes/Ember.Templates.helpers/methods/on?anchor=on) +- [Component](https://api.emberjs.com/ember/5.8/modules/@glimmer%2Fcomponent) +- [tracked](https://api.emberjs.com/ember/5.8/functions/@glimmer%2Ftracking/tracked) diff --git a/apps/tutorial/public/docs/8-form-data-controlled/3-radio/answer.gjs b/apps/tutorial/public/docs/8-form-data-controlled/3-radio/answer.gjs new file mode 100644 index 000000000..d7194e67a --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/3-radio/answer.gjs @@ -0,0 +1,68 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; + +class ControlledInput extends Component { + handleChange = (event) => { + let radio = event.target; + + this.args.onChange(radio.value); + } + + isChecked = (value) => this.args.value === value + + +} + +// Below is only setup for the tutorial chapter +// and not exactly relevent to the topic +class State { + @tracked value; + handleChange = (newValue) => this.value = newValue; +} +const createState = () => new State(); + + + diff --git a/apps/tutorial/public/docs/8-form-data-controlled/3-radio/prompt.gjs b/apps/tutorial/public/docs/8-form-data-controlled/3-radio/prompt.gjs new file mode 100644 index 000000000..7cfdad46d --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/3-radio/prompt.gjs @@ -0,0 +1,54 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; + +class ControlledInput extends Component { + +} + +// Below is only setup for the tutorial chapter +// and not exactly relevent to the topic +class State { + @tracked value; + handleChange = (newValue) => this.value = newValue; +} +const createState = () => new State(); + + + diff --git a/apps/tutorial/public/docs/8-form-data-controlled/3-radio/prose.md b/apps/tutorial/public/docs/8-form-data-controlled/3-radio/prose.md new file mode 100644 index 000000000..3fe215064 --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/3-radio/prose.md @@ -0,0 +1,49 @@ +Just like the controlled input, the _controlled radio button_ is roughly the same approach (conceptually), but we because we now have a list of options where only one can be active at a time, the way in which we set the "selected option" as well as how we handle the events will be very different. + +Instead of setting value on a single input, we'll set `checked` to be the result of a function call on _each option_: +```gjs +class Demo extends Component { + isChecked = (value) => this.args.value === value + + +} +``` + +And for the event binding, we'll use the `change` event on each of the radio inputs as well. +```gjs +class Demo extends Component { + handleChange = (event) => { + let radio = event.target; + + this.args.onChange(radio.value); + } + + +} +``` + +

+ Change the radio buttons within the ControlledInput component + to be controlled. +

+ + diff --git a/apps/tutorial/public/docs/8-form-data-controlled/4-select/answer.gjs b/apps/tutorial/public/docs/8-form-data-controlled/4-select/answer.gjs new file mode 100644 index 000000000..2c6a193a0 --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/4-select/answer.gjs @@ -0,0 +1,49 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; + +class ControlledInput extends Component { + handleChange = (event) => { + let select = event.target; + + this.args.onChange(select.value); + } + + isSelected = (value) => this.args.value === value + + +} + +// Below is only setup for the tutorial chapter +// and not exactly relevent to the topic +class State { + @tracked value; + handleChange = (newValue) => this.value = newValue; +} +const createState = () => new State(); + + + diff --git a/apps/tutorial/public/docs/8-form-data-controlled/4-select/prompt.gjs b/apps/tutorial/public/docs/8-form-data-controlled/4-select/prompt.gjs new file mode 100644 index 000000000..b85bbd7fe --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/4-select/prompt.gjs @@ -0,0 +1,41 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; + +class ControlledInput extends Component { + +} + +// Below is only setup for the tutorial chapter +// and not exactly relevent to the topic +class State { + @tracked value; + handleChange = (newValue) => this.value = newValue; +} +const createState = () => new State(); + + + diff --git a/apps/tutorial/public/docs/8-form-data-controlled/4-select/prose.md b/apps/tutorial/public/docs/8-form-data-controlled/4-select/prose.md new file mode 100644 index 000000000..770398009 --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/4-select/prose.md @@ -0,0 +1,40 @@ +Just like the controlled input, the _controlled select_ is roughly the same approach (conceptually), but we because we now have a list of options where only one can be active at a time, the way in which we set the "selected option" as well as how we handle the events will be very different. + +Instead of setting value on a single input, we'll set `selected` to be the result of a function call on _each option_: +```gjs +class Demo extends Component { + isSelected = (value) => this.args.value === value + + +} +``` + +And for the event binding, we'll use the `change` event on the single select element. +```gjs +class Demo extends Component { + handleChange = (event) => { + let select = event.target; + + this.args.onChange(select.value); + } + + +} +``` + +

+ Change the select within the ControlledInput component + to be controlled. +

+ + diff --git a/apps/tutorial/public/docs/8-form-data-controlled/5-select-multiple/answer.gjs b/apps/tutorial/public/docs/8-form-data-controlled/5-select-multiple/answer.gjs new file mode 100644 index 000000000..6de6d578a --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/5-select-multiple/answer.gjs @@ -0,0 +1,53 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; + +class ControlledInput extends Component { + handleChange = (event) => { + let select = event.target; + let selected = [...select.options] + .filter(option => option.selected) + .map(option => option.value); + + this.args.onChange(selected); + } + + isSelected = (value) => this.args.value?.includes(value); + + +} + +// Below is only setup for the tutorial chapter +// and not exactly relevent to the topic +class State { + @tracked value; + handleChange = (newValue) => this.value = newValue; +} +const createState = () => new State(); + + + + diff --git a/apps/tutorial/public/docs/8-form-data-controlled/5-select-multiple/prompt.gjs b/apps/tutorial/public/docs/8-form-data-controlled/5-select-multiple/prompt.gjs new file mode 100644 index 000000000..eb2e4023f --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/5-select-multiple/prompt.gjs @@ -0,0 +1,42 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; + +class ControlledInput extends Component { + +} + +// Below is only setup for the tutorial chapter +// and not exactly relevent to the topic +class State { + @tracked value; + handleChange = (newValue) => this.value = newValue; +} +const createState = () => new State(); + + + + diff --git a/apps/tutorial/public/docs/8-form-data-controlled/5-select-multiple/prose.md b/apps/tutorial/public/docs/8-form-data-controlled/5-select-multiple/prose.md new file mode 100644 index 000000000..e5d73051a --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/5-select-multiple/prose.md @@ -0,0 +1,44 @@ +Making a select multiple field _controlled_ is nearly the same as making a non-multiple select field controlled -- the main difference is that we now need to deal with array data (manually). + +Our value now represents an array of known options, rather than a single value, so our `isSelected` function must be updated: +```gjs +class Demo extends Component { + // Note the `?.` because the initial value isn't set. + isSelected = (value) => this.args.value?.includes(value); + + +} +``` + +And for the event binding, we'll use the `change` event on the single select element. However, this time we need to do some processing to figure out what the selected array is. +```gjs +class Demo extends Component { + handleChange = (event) => { + let select = event.target; + let selected = [...select.options] + .filter(option => option.selected) + .map(option => option.value); + + this.args.onChange(selected); + } + + +} +``` + +

+ Change the select within the ControlledInput component + to be controlled. +

+ + diff --git a/apps/tutorial/public/docs/8-form-data-controlled/6-textarea/answer.gjs b/apps/tutorial/public/docs/8-form-data-controlled/6-textarea/answer.gjs new file mode 100644 index 000000000..d97fb0c59 --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/6-textarea/answer.gjs @@ -0,0 +1,45 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; + +class ControlledInput extends Component { + handleChange = (event) => { + let textarea = event.target; + + this.args.onChange(textarea.value); + } + + +} + +// Below is only setup for the tutorial chapter +// and not exactly relevent to the topic +class State { + @tracked value = "initial text"; + handleChange = (newValue) => this.value = newValue; +} +const createState = () => new State(); + + + + diff --git a/apps/tutorial/public/docs/8-form-data-controlled/6-textarea/prompt.gjs b/apps/tutorial/public/docs/8-form-data-controlled/6-textarea/prompt.gjs new file mode 100644 index 000000000..d92a41dc7 --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/6-textarea/prompt.gjs @@ -0,0 +1,45 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; + +class ControlledInput extends Component { + handleChange = (event) => { + let textarea = event.target; + + this.args.onChange(textarea.value); + } + + +} + +// Below is only setup for the tutorial chapter +// and not exactly relevent to the topic +class State { + @tracked value = "initial text"; + handleChange = (newValue) => this.value = newValue; +} +const createState = () => new State(); + + + + diff --git a/apps/tutorial/public/docs/8-form-data-controlled/6-textarea/prose.md b/apps/tutorial/public/docs/8-form-data-controlled/6-textarea/prose.md new file mode 100644 index 000000000..5999977b4 --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/6-textarea/prose.md @@ -0,0 +1,32 @@ +Making the textarea element _controlled_ is similar to a text input. The only difference is where the value is set. + +We can set the value by placing it in the content of the textarea element. +```gjs +class Demo extends Component { + +} +``` + +And for the event binding, we can use the `input` event to have live updates as we type: +```gjs +class Demo extends Component { + handleChange = (event) => { + let textarea = event.target; + + this.args.onChange(textarea.value); + } + + +} +``` + +

+ Change the textarea within the ControlledInput component + to be controlled. +

+ + diff --git a/apps/tutorial/public/docs/8-form-data-controlled/7-contenteditable/answer.gjs b/apps/tutorial/public/docs/8-form-data-controlled/7-contenteditable/answer.gjs new file mode 100644 index 000000000..f80478fff --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/7-contenteditable/answer.gjs @@ -0,0 +1,70 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; +import { modifier } from 'ember-modifier'; + +let { document } = globalThis; +const bold = () => document.execCommand("bold", false, null); +const italic = () => document.execCommand("italic", false, null); +const underline = () => document.execCommand("underline", false, null); +const list = () => document.execCommand("insertUnorderedList", false, null); + +const setContent = modifier((element, [initialize]) => { + (async () => { + // Disconnect from auto-tracking + // so we can only set the inner HTML once + await Promise.resolve(); + initialize(element); + })(); +}); + +class ControlledInput extends Component { + handleChange = (event) => { + let content = event.target; + + this.args.onChange(content.innerHTML); + } + + setValue = (element) => element.innerHTML = this.args.value; + + +} + +// Below is only setup for the tutorial chapter +// and not exactly relevent to the topic +class State { + @tracked value = "initial text"; + handleChange = (newValue) => this.value = newValue; +} +const createState = () => new State(); + + + + diff --git a/apps/tutorial/public/docs/8-form-data-controlled/7-contenteditable/prompt.gjs b/apps/tutorial/public/docs/8-form-data-controlled/7-contenteditable/prompt.gjs new file mode 100644 index 000000000..474ae7810 --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/7-contenteditable/prompt.gjs @@ -0,0 +1,52 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; +import { modifier } from 'ember-modifier'; + +let { document } = globalThis; +const bold = () => document.execCommand("bold", false, null); +const italic = () => document.execCommand("italic", false, null); +const underline = () => document.execCommand("underline", false, null); +const list = () => document.execCommand("insertUnorderedList", false, null); + +class ControlledInput extends Component { + +} + +// Below is only setup for the tutorial chapter +// and not exactly relevent to the topic +class State { + @tracked value = "initial text"; + handleChange = (newValue) => this.value = newValue; +} +const createState = () => new State(); + + + + diff --git a/apps/tutorial/public/docs/8-form-data-controlled/7-contenteditable/prose.md b/apps/tutorial/public/docs/8-form-data-controlled/7-contenteditable/prose.md new file mode 100644 index 000000000..ddbec06b8 --- /dev/null +++ b/apps/tutorial/public/docs/8-form-data-controlled/7-contenteditable/prose.md @@ -0,0 +1,67 @@ +Because contenteditable has browser-implemented internal state, it's not *exactly* able to be controlled. But we can set the initial value, and listen to updates as they happen. + + +For setting the initial value we need to _disconnect_ from auto-tracking. +This can be done, inline, with an async immediately invoked function execution (IIFE), +```js +(async () => { + await Promise.resolve(); + // code that access tracked state here + // ... +})() +``` +This defers execution of "the real code" to the "next" [microtask](https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide/In_depth). +We also need to get a reference to the element, so we'll need to create a modifier for where our async IIFE can live: +```js +import { modifier } from 'ember-modifier'; + +const setContent = modifier((element, [initialize]) => { + (async () => { + // Disconnect from auto-tracking + // so we can only set the inner HTML once + await Promise.resolve(); + initialize(element); + })(); +}); +``` +But then we need to use the modifier in our contenteditable: +```gjs +class Demo extends Component { + setValue = (element) => element.innerHTML = this.args.value; + + +} +``` + +And for the event binding, we can use the `input` event to have live updates as we type: +```gjs +class Demo extends Component { + handleChange = (event) => { + let textarea = event.target; + + this.args.onChange(textarea.innerHTML); + } + + +} +``` + +Unlike the other controlled inputs we've covered so far, we dan't expect to do anything with the updated `@value` once it's passed back in to our `contenteditable` component. `contenteditable` has its own state, and if we were to retain an fully _controlled_ `@value`, we would then need to manage the cursor position within the contenteditable element, and that is a lot of code. + +

+ Change the contenteditable within the ControlledInput component + to have an initial value set and an input event listener. +

+ +