+
+
diff --git a/apps/tutorial/public/docs/4-logic/5-conditional-attributes/prose.md b/apps/tutorial/public/docs/4-logic/5-conditional-attributes/prose.md
new file mode 100644
index 000000000..4fac6c772
--- /dev/null
+++ b/apps/tutorial/public/docs/4-logic/5-conditional-attributes/prose.md
@@ -0,0 +1,56 @@
+# Conditional Attribute Rendering
+
+In Ember/Glimmer templates, you can conditionally render attributes based on certain conditions. This is particularly useful for:
+
+- Toggling CSS classes
+- Setting ARIA attributes
+- Conditionally applying styles
+- Enabling/disabling form elements
+
+## Using Inline If for Attributes
+
+The inline `{{if}}` helper is perfect for conditional attribute rendering:
+
+```hbs
+
Content
+```
+
+You can also use it for boolean attributes:
+
+```hbs
+
+```
+
+Or even more concisely:
+
+```hbs
+
+```
+
+## Combining Multiple Conditions
+
+For more complex conditions, you can combine helpers:
+
+```hbs
+
+ Content
+
+```
+
+
+ Complete the ConditionalAttributes component by implementing the missing conditional attributes:
+
+
Add a conditional class to the button based on its state
+
Add a conditional disabled attribute to the button when loading
+
Add conditional ARIA attributes to the notification based on its visibility
+
+
+
+[Documentation for if helper][docs-if]
+[Documentation for class attribute binding][docs-class-binding]
+
+[docs-if]: https://api.emberjs.com/ember/release/classes/Ember.Templates.helpers/methods/if?anchor=if
+[docs-class-binding]: https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/#toc_class-and-attribute-bindings
diff --git a/apps/tutorial/public/docs/5-builtins/4-sanitizing-html/answer.gjs b/apps/tutorial/public/docs/5-builtins/4-sanitizing-html/answer.gjs
new file mode 100644
index 000000000..656319203
--- /dev/null
+++ b/apps/tutorial/public/docs/5-builtins/4-sanitizing-html/answer.gjs
@@ -0,0 +1,43 @@
+import Component from '@glimmer/component';
+import { htmlSafe } from '@ember/template';
+
+// Import DOMPurify (in a real app, you would install this package)
+const DOMPurify = {
+ sanitize: (html, options = {}) => {
+ // This is a simplified version of DOMPurify for demonstration
+ // It removes script tags and onclick attributes
+ return html
+ .replace(/ script tag" />
+
+
Another Unsafe Example
+
+ Dangerous Button" />
+
diff --git a/apps/tutorial/public/docs/5-builtins/4-sanitizing-html/prompt.gjs b/apps/tutorial/public/docs/5-builtins/4-sanitizing-html/prompt.gjs
new file mode 100644
index 000000000..6f3f4fcad
--- /dev/null
+++ b/apps/tutorial/public/docs/5-builtins/4-sanitizing-html/prompt.gjs
@@ -0,0 +1,40 @@
+import Component from '@glimmer/component';
+import { htmlSafe } from '@ember/template';
+
+// Import DOMPurify (in a real app, you would install this package)
+const DOMPurify = {
+ sanitize: (html, options = {}) => {
+ // This is a simplified version of DOMPurify for demonstration
+ // It removes script tags and onclick attributes
+ return html
+ .replace(/ script tag" />
+
+
Another Unsafe Example
+
+ Dangerous Button" />
+
diff --git a/apps/tutorial/public/docs/5-builtins/4-sanitizing-html/prose.md b/apps/tutorial/public/docs/5-builtins/4-sanitizing-html/prose.md
new file mode 100644
index 000000000..4e9fc3fa9
--- /dev/null
+++ b/apps/tutorial/public/docs/5-builtins/4-sanitizing-html/prose.md
@@ -0,0 +1,23 @@
+# Sanitizing HTML Content
+
+When working with user-generated content or HTML from external sources, it's crucial to sanitize the HTML to prevent [Cross-Site Scripting (XSS)][xss] attacks. While Ember's `htmlSafe` tells the framework not to escape HTML content, it doesn't actually sanitize the content.
+
+For proper sanitization, you need to use a dedicated HTML sanitizer like [DOMPurify][dompurify]. This library removes potentially dangerous content while preserving safe HTML elements and attributes.
+
+The basic pattern for safely rendering user-generated HTML is:
+
+1. Sanitize the HTML using DOMPurify
+2. Mark the sanitized content as safe using `htmlSafe`
+3. Render the safe content in your template
+
+
+ Complete the SafeHtml component to properly sanitize and render the HTML content.
+
+
+[Documentation for htmlSafe][docs-htmlsafe]
+[Documentation for DOMPurify][docs-dompurify]
+
+[xss]: https://developer.mozilla.org/en-US/docs/Glossary/Cross-site_scripting
+[dompurify]: https://github.com/cure53/DOMPurify
+[docs-htmlsafe]: https://api.emberjs.com/ember/release/functions/@ember%2Ftemplate/htmlSafe
+[docs-dompurify]: https://github.com/cure53/DOMPurify#readme
diff --git a/apps/tutorial/public/docs/6-component-patterns/10-modal/answer.gjs b/apps/tutorial/public/docs/6-component-patterns/10-modal/answer.gjs
new file mode 100644
index 000000000..74116402d
--- /dev/null
+++ b/apps/tutorial/public/docs/6-component-patterns/10-modal/answer.gjs
@@ -0,0 +1,87 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+
+// Helper function to find DOM elements
+const findElement = (selector) => document.querySelector(selector);
+
+// Ensure we have a modal container in the DOM
+if (!findElement('#modal-container')) {
+ const modalContainer = document.createElement('div');
+ modalContainer.id = 'modal-container';
+ document.body.appendChild(modalContainer);
+}
+
+class Modal extends Component {
+ @action
+ closeModal() {
+ if (this.args.onClose) {
+ this.args.onClose();
+ }
+ }
+
+
+ {{#in-element (findElement '#modal-container')}}
+
+
+
+ {{/if}}
+
+
+
+}
+
+
+
+
diff --git a/apps/tutorial/public/docs/6-component-patterns/10-modal/prose.md b/apps/tutorial/public/docs/6-component-patterns/10-modal/prose.md
new file mode 100644
index 000000000..6910a6f79
--- /dev/null
+++ b/apps/tutorial/public/docs/6-component-patterns/10-modal/prose.md
@@ -0,0 +1,25 @@
+# Creating a Modal Component
+
+Modal dialogs are a common UI pattern that display content in a layer above the page, typically with a semi-transparent backdrop that prevents interaction with the page underneath.
+
+Building a modal in Ember/Glimmer involves several key concepts:
+1. **Portalling** - Using `in-element` to render the modal outside of the normal DOM flow
+2. **Conditional rendering** - Showing/hiding the modal based on state
+3. **Event handling** - Closing the modal when clicking outside or pressing escape
+4. **Accessibility** - Ensuring the modal is accessible to all users
+
+For this tutorial, we'll focus on creating a basic modal component that uses portalling to render outside the normal DOM flow and includes a backdrop that closes the modal when clicked.
+
+
+ Complete the Modal component by:
+
+
Using in-element to render the modal content in the modal-container
+
Adding an event handler to close the modal when the backdrop is clicked
+
+
+
+[Documentation for in-element][docs-in-element]
+[Documentation for on modifier][docs-on]
+
+[docs-in-element]: https://api.emberjs.com/ember/release/classes/Ember.Templates.helpers/methods/in-element?anchor=in-element
+[docs-on]: https://api.emberjs.com/ember/release/classes/Ember.Templates.helpers/methods/on?anchor=on
diff --git a/apps/tutorial/public/docs/6-component-patterns/9-portalling/answer.gjs b/apps/tutorial/public/docs/6-component-patterns/9-portalling/answer.gjs
index c4d11945f..d9903eb00 100644
--- a/apps/tutorial/public/docs/6-component-patterns/9-portalling/answer.gjs
+++ b/apps/tutorial/public/docs/6-component-patterns/9-portalling/answer.gjs
@@ -1,5 +1,46 @@
+import Component from '@glimmer/component';
+
+// Helper function to find DOM elements
+const findElement = (selector) => document.querySelector(selector);
+
+class Portal extends Component {
+
+ {{! The portal target where content will be rendered }}
+
+
+
+ {{! Use in-element to render the yielded content in the portal-target }}
+ {{#in-element (findElement '#portal-target')}}
+ {{yield}}
+ {{/in-element}}
+
+
+}
+
- This tutorial chapter needs to be written!
+
+
+ This content should appear in the portal target!
+
+
- It could be written by you!, if you want <3
+
diff --git a/apps/tutorial/public/docs/6-component-patterns/9-portalling/prompt.gjs b/apps/tutorial/public/docs/6-component-patterns/9-portalling/prompt.gjs
index c4d11945f..f106987a6 100644
--- a/apps/tutorial/public/docs/6-component-patterns/9-portalling/prompt.gjs
+++ b/apps/tutorial/public/docs/6-component-patterns/9-portalling/prompt.gjs
@@ -1,5 +1,44 @@
+import Component from '@glimmer/component';
+
+// Helper function to find DOM elements
+const findElement = (selector) => document.querySelector(selector);
+
+class Portal extends Component {
+
+ {{! The portal target where content will be rendered }}
+
+
+
+ {{! TODO: Use in-element to render the yielded content in the portal-target }}
+ {{yield}}
+
+
+}
+
- This tutorial chapter needs to be written!
+
+
+ This content should appear in the portal target!
+
+
- It could be written by you!, if you want <3
+
diff --git a/apps/tutorial/public/docs/6-component-patterns/9-portalling/prose.md b/apps/tutorial/public/docs/6-component-patterns/9-portalling/prose.md
index 3fd0b815a..f672c53bc 100644
--- a/apps/tutorial/public/docs/6-component-patterns/9-portalling/prose.md
+++ b/apps/tutorial/public/docs/6-component-patterns/9-portalling/prose.md
@@ -1,3 +1,25 @@
-This tutorial chapter needs to be written!
+# Portalling with in-element
-It could be written by you!, if you want <3
+Portalling is a technique that allows you to render content in a different part of the DOM than where the component is defined. This is particularly useful for modals, tooltips, and other UI elements that need to break out of their parent's layout constraints.
+
+In Ember/Glimmer, portalling is achieved using the `in-element` helper, which takes a DOM element reference and renders its content into that element.
+
+```hbs
+{{#in-element (findElement '#portal-target')}}
+ Content to render elsewhere
+{{/in-element}}
+```
+
+This is especially useful for:
+- Modal dialogs that need to appear above all other content
+- Tooltips that need to break out of overflow: hidden containers
+- Dropdown menus that need to extend beyond their parent's boundaries
+- Any content that needs to escape z-index stacking contexts
+
+
+ Complete the Portal component to render its content in the target element.
+
+
+[Documentation][docs-in-element]
+
+[docs-in-element]: https://api.emberjs.com/ember/release/classes/Ember.Templates.helpers/methods/in-element?anchor=in-element
diff --git a/apps/tutorial/public/docs/6-component-patterns/dynamic-rendering/answer.gjs b/apps/tutorial/public/docs/6-component-patterns/dynamic-rendering/answer.gjs
new file mode 100644
index 000000000..218aa8536
--- /dev/null
+++ b/apps/tutorial/public/docs/6-component-patterns/dynamic-rendering/answer.gjs
@@ -0,0 +1,459 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+
+// Simple Card Component
+class CardComponent extends Component {
+
+
This allows us to switch between different components at runtime based on user selection.
+
+
+
+
+
+}
+
+
+
Dynamic Rendering in Ember
+
+
diff --git a/apps/tutorial/public/docs/6-component-patterns/dynamic-rendering/prompt.gjs b/apps/tutorial/public/docs/6-component-patterns/dynamic-rendering/prompt.gjs
new file mode 100644
index 000000000..174ea2dfd
--- /dev/null
+++ b/apps/tutorial/public/docs/6-component-patterns/dynamic-rendering/prompt.gjs
@@ -0,0 +1,70 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+
+// TODO: Create simple components that will be dynamically rendered
+
+class DynamicRenderingDemo extends Component {
+ // TODO: Add tracked properties for component selection
+
+ // TODO: Add computed properties for dynamic component selection
+
+ // TODO: Add actions for changing the selected component
+
+
+
+
Dynamic Rendering Demo
+
+
+ {{! TODO: Add controls for selecting components }}
+
+
+
+ {{! TODO: Dynamically render the selected component }}
+
+
+
+
+
+}
+
+
+
Dynamic Rendering in Ember
+
+
diff --git a/apps/tutorial/public/docs/6-component-patterns/dynamic-rendering/prose.md b/apps/tutorial/public/docs/6-component-patterns/dynamic-rendering/prose.md
new file mode 100644
index 000000000..3e269fa8a
--- /dev/null
+++ b/apps/tutorial/public/docs/6-component-patterns/dynamic-rendering/prose.md
@@ -0,0 +1,78 @@
+# Dynamic Rendering in Ember
+
+Dynamic rendering is a powerful pattern that allows you to conditionally render different components based on runtime conditions. This approach enables more flexible and adaptable user interfaces that can change their presentation based on data, user preferences, or application state.
+
+## Understanding Dynamic Rendering
+
+When working with dynamic rendering in Ember, it's important to understand:
+
+1. Components can be dynamically selected at runtime
+2. You can use the `component` helper to render components by name
+3. Component names can be stored in variables or computed properties
+4. You can pass arguments to dynamically rendered components
+
+## Basic Dynamic Rendering
+
+The simplest form of dynamic rendering uses the `component` helper:
+
+```hbs
+{{component this.componentName}}
+```
+
+Where `componentName` is a string that corresponds to a component name:
+
+```js
+@tracked componentName = 'info-panel';
+```
+
+## Passing Arguments to Dynamic Components
+
+You can pass arguments to dynamically rendered components:
+
+```hbs
+{{component this.componentName
+ title=this.title
+ content=this.content
+ onAction=this.handleAction
+}}
+```
+
+## Conditional Component Selection
+
+You can use computed properties to determine which component to render:
+
+```js
+get displayComponent() {
+ if (this.isLoading) {
+ return 'loading-spinner';
+ } else if (this.hasError) {
+ return 'error-message';
+ } else if (this.isEmpty) {
+ return 'empty-state';
+ } else {
+ return 'data-display';
+ }
+}
+```
+
+```hbs
+{{component this.displayComponent data=this.data}}
+```
+
+
+ Complete the DynamicRenderingDemo component by:
+
+
Implementing a component that dynamically renders different UI components based on a selection
+
Creating a set of simple components that can be dynamically rendered
+
Adding controls to change which component is rendered
+
Implementing a way to pass different arguments to the dynamically rendered components
+
+
+
+[Documentation for component helper][ember-component-helper]
+[Documentation for conditional rendering][ember-conditional-rendering]
+[Documentation for computed properties][ember-computed-properties]
+
+[ember-component-helper]: https://guides.emberjs.com/release/components/component-arguments-and-html-attributes/#toc_dynamic-component-invocation
+[ember-conditional-rendering]: https://guides.emberjs.com/release/components/conditional-content/
+[ember-computed-properties]: https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/#toc_computed-properties
diff --git a/apps/tutorial/public/docs/6-component-patterns/portalling/answer.gjs b/apps/tutorial/public/docs/6-component-patterns/portalling/answer.gjs
new file mode 100644
index 000000000..17211b704
--- /dev/null
+++ b/apps/tutorial/public/docs/6-component-patterns/portalling/answer.gjs
@@ -0,0 +1,310 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+
+class PortalledContent extends Component {
+ @action
+ close() {
+ this.args.onClose();
+ }
+
+
+
+
+
{{@title}}
+
+
+
+
+
{{@content}}
+
+ {{#if @showInput}}
+
+
+
+
+ {{/if}}
+
+
+
+
+
+}
+
+class PortallingDemo extends Component {
+ // Track whether the portal is visible
+ @tracked isPortalVisible = false;
+
+ // Track the content for the portal
+ @tracked portalTitle = 'Portalled Content';
+ @tracked portalContent = 'This content is rendered in a different part of the DOM using the {{in-element}} helper.';
+
+ // Track input value for demonstration
+ @tracked inputValue = '';
+ @tracked showInput = true;
+
+ // Get a reference to the portal target element
+ get portalTarget() {
+ return document.getElementById('portal-target');
+ }
+
+ // Actions
+ @action
+ showPortal() {
+ this.isPortalVisible = true;
+ }
+
+ @action
+ hidePortal() {
+ this.isPortalVisible = false;
+ }
+
+ @action
+ updateInputValue(event) {
+ this.inputValue = event.target.value;
+ }
+
+ @action
+ handleConfirm() {
+ alert(`Confirmed with value: ${this.inputValue || 'No value entered'}`);
+ this.hidePortal();
+ }
+
+ @action
+ toggleInput() {
+ this.showInput = !this.showInput;
+ }
+
+
+
+
Portalling Demo
+
+
+
This is the main content of the component. The button below will trigger content to be rendered in a different part of the DOM.
+
+
+
+ {{#if this.showInput}}
+
+
Current input value: {{if this.inputValue this.inputValue "No value entered"}}
+ {{#if this.isPortalVisible}}
+ Content is currently being portalled to this element.
+ {{else}}
+ This is the portal target. Content will be portalled here when the button is clicked.
+ {{/if}}
+
+
+
+
+
How It Works
+
This component demonstrates portalling using the {{in-element}} helper:
+
+
diff --git a/apps/tutorial/public/docs/6-component-patterns/portalling/prose.md b/apps/tutorial/public/docs/6-component-patterns/portalling/prose.md
new file mode 100644
index 000000000..b84ebc106
--- /dev/null
+++ b/apps/tutorial/public/docs/6-component-patterns/portalling/prose.md
@@ -0,0 +1,74 @@
+# Portalling in Ember
+
+Portalling is a technique that allows you to render content in a different part of the DOM tree than where the component is defined. This is particularly useful for creating modals, tooltips, dropdowns, and other UI elements that need to break out of their parent's layout constraints.
+
+## Understanding Portalling
+
+When working with portalling in Ember, it's important to understand:
+
+1. Content is defined in one location but rendered elsewhere in the DOM
+2. The `{{in-element}}` helper is used to move content to a different DOM element
+3. Portalling maintains the component's context and reactivity
+4. The target element must exist in the DOM before portalling content to it
+
+## Basic Portalling with {{in-element}}
+
+The `{{in-element}}` helper is the primary way to implement portalling in Ember:
+
+```hbs
+{{#in-element this.targetElement}}
+
+ This content will be rendered in the target element.
+
+{{/in-element}}
+```
+
+Where `targetElement` is a reference to a DOM element:
+
+```js
+get targetElement() {
+ return document.getElementById('portal-target');
+}
+```
+
+## Creating a Portal Target
+
+Before you can portal content, you need a target element in the DOM:
+
+```html
+
+```
+
+This element is typically placed at the root level of your application, outside of any layout constraints.
+
+## Maintaining Context and Reactivity
+
+Content rendered through a portal maintains its component context and reactivity:
+
+```hbs
+{{#in-element this.targetElement}}
+
+
{{@title}}
+
{{@content}}
+
+
+{{/in-element}}
+```
+
+
+ Complete the PortallingDemo component by:
+
+
Implementing a portal that renders content in a different part of the DOM
+
Creating a button that triggers the display of portalled content
+
Ensuring the portalled content maintains its component context and reactivity
+
Adding a way to close or hide the portalled content
+
+
+
+[Documentation for in-element helper][ember-in-element]
+[Documentation for DOM manipulation in Ember][ember-dom-manipulation]
+[Documentation for component lifecycle][ember-component-lifecycle]
+
+[ember-in-element]: https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/#toc_in-element
+[ember-dom-manipulation]: https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/
+[ember-component-lifecycle]: https://guides.emberjs.com/release/components/component-state-and-actions/
diff --git a/apps/tutorial/public/docs/7-css-animations/1-transitions/answer.gjs b/apps/tutorial/public/docs/7-css-animations/1-transitions/answer.gjs
new file mode 100644
index 000000000..61ad3f05a
--- /dev/null
+++ b/apps/tutorial/public/docs/7-css-animations/1-transitions/answer.gjs
@@ -0,0 +1,87 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+
+class TransitionDemo extends Component {
+ @tracked isExpanded = false;
+
+ @action
+ toggleExpand() {
+ this.isExpanded = !this.isExpanded;
+ }
+
+
+
+
+
+
+
+
Hover over me
+
This card should scale slightly on hover
+
+
+
+
+
This section expands and collapses with a smooth transition
+
The height should animate smoothly
+
+
+
+
+
+}
+
+
+
CSS Transitions Demo
+
+
diff --git a/apps/tutorial/public/docs/7-css-animations/1-transitions/prompt.gjs b/apps/tutorial/public/docs/7-css-animations/1-transitions/prompt.gjs
new file mode 100644
index 000000000..cc08c2f05
--- /dev/null
+++ b/apps/tutorial/public/docs/7-css-animations/1-transitions/prompt.gjs
@@ -0,0 +1,87 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+
+class TransitionDemo extends Component {
+ @tracked isExpanded = false;
+
+ @action
+ toggleExpand() {
+ this.isExpanded = !this.isExpanded;
+ }
+
+
+
+
+
+
+
+
Hover over me
+
This card should scale slightly on hover
+
+
+
+
+
This section expands and collapses with a smooth transition
+
The height should animate smoothly
+
+
+
+
+
+}
+
+
+
CSS Transitions Demo
+
+
diff --git a/apps/tutorial/public/docs/7-css-animations/1-transitions/prose.md b/apps/tutorial/public/docs/7-css-animations/1-transitions/prose.md
new file mode 100644
index 000000000..012ab6de5
--- /dev/null
+++ b/apps/tutorial/public/docs/7-css-animations/1-transitions/prose.md
@@ -0,0 +1,49 @@
+# CSS Transitions
+
+CSS transitions provide a way to control animation speed when changing CSS properties. Instead of having property changes take effect immediately, you can cause the changes to take place over a period of time.
+
+Transitions are a powerful way to enhance user experience by providing visual feedback for state changes in your application.
+
+## Basic Transition Syntax
+
+The basic syntax for CSS transitions includes:
+
+```css
+.element {
+ transition-property: property-to-animate;
+ transition-duration: time-in-seconds;
+ transition-timing-function: easing-function;
+ transition-delay: time-in-seconds;
+}
+```
+
+Or using the shorthand:
+
+```css
+.element {
+ transition: property-to-animate time-in-seconds easing-function delay;
+}
+```
+
+## Common Use Cases
+
+Transitions are commonly used for:
+- Hover effects
+- Button state changes
+- Expanding/collapsing elements
+- Fading elements in/out
+
+
+ Complete the TransitionDemo component by implementing the missing CSS transitions:
+
+
Add a color transition to the button on hover
+
Add a transform transition to the card on hover
+
Add a height transition to the expandable section
+
+
+
+[Documentation for CSS Transitions][mdn-transitions]
+[Documentation for Ember Modifiers][docs-modifiers]
+
+[mdn-transitions]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Transitions/Using_CSS_transitions
+[docs-modifiers]: https://api.emberjs.com/ember/release/modules/@ember%2Fmodifier
diff --git a/apps/tutorial/public/docs/7-css-animations/2-transforms/answer.gjs b/apps/tutorial/public/docs/7-css-animations/2-transforms/answer.gjs
new file mode 100644
index 000000000..4d72023e2
--- /dev/null
+++ b/apps/tutorial/public/docs/7-css-animations/2-transforms/answer.gjs
@@ -0,0 +1,98 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+
+class TransformDemo extends Component {
+ @tracked activeCard = null;
+
+ @action
+ setActiveCard(cardId) {
+ this.activeCard = cardId;
+ }
+
+ @action
+ resetActiveCard() {
+ this.activeCard = null;
+ }
+
+
+
+
+
diff --git a/apps/tutorial/public/docs/7-css-animations/2-transforms/prose.md b/apps/tutorial/public/docs/7-css-animations/2-transforms/prose.md
new file mode 100644
index 000000000..95e14c540
--- /dev/null
+++ b/apps/tutorial/public/docs/7-css-animations/2-transforms/prose.md
@@ -0,0 +1,46 @@
+# CSS Transforms
+
+CSS transforms allow you to modify the appearance and position of elements without affecting the document flow. You can rotate, scale, skew, or translate elements in 2D or 3D space.
+
+Transforms are often combined with transitions to create smooth animations.
+
+## Basic Transform Syntax
+
+The basic syntax for CSS transforms is:
+
+```css
+.element {
+ transform: function(value);
+}
+```
+
+Common transform functions include:
+- `translate(x, y)` - Moves an element from its current position
+- `scale(x, y)` - Changes the size of an element
+- `rotate(angle)` - Rotates an element around a fixed point
+- `skew(x-angle, y-angle)` - Skews an element along the X and Y axes
+
+## Combining Transforms
+
+You can combine multiple transforms in a single declaration:
+
+```css
+.element {
+ transform: translateX(10px) rotate(45deg) scale(1.5);
+}
+```
+
+
+ Complete the TransformDemo component by implementing the missing CSS transforms:
+
+
Add a rotation transform to the first card
+
Add a skew transform to the second card
+
Add a combined transform (translate and scale) to the third card
+
+
+
+[Documentation for CSS Transforms][mdn-transforms]
+[Documentation for Ember Modifiers][docs-modifiers]
+
+[mdn-transforms]: https://developer.mozilla.org/en-US/docs/Web/CSS/transform
+[docs-modifiers]: https://api.emberjs.com/ember/release/modules/@ember%2Fmodifier
diff --git a/apps/tutorial/public/docs/7-css-animations/3-keyframes/answer.gjs b/apps/tutorial/public/docs/7-css-animations/3-keyframes/answer.gjs
new file mode 100644
index 000000000..dc9455f43
--- /dev/null
+++ b/apps/tutorial/public/docs/7-css-animations/3-keyframes/answer.gjs
@@ -0,0 +1,138 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+
+class KeyframeDemo extends Component {
+ @tracked showMessage = false;
+ @tracked notificationCount = 3;
+
+ @action
+ toggleMessage() {
+ this.showMessage = !this.showMessage;
+ }
+
+
+
+
+
Keyframe Animations
+
+
+
+ {{this.notificationCount}}
+
+
+
+
+
+
+ {{#if this.showMessage}}
+
+
This message should fade in with a keyframe animation!
+
Keyframe animations allow for more complex animation sequences than simple transitions.
+
+
diff --git a/apps/tutorial/public/docs/7-css-animations/4-matrix-transforms/prose.md b/apps/tutorial/public/docs/7-css-animations/4-matrix-transforms/prose.md
new file mode 100644
index 000000000..835b4aa3e
--- /dev/null
+++ b/apps/tutorial/public/docs/7-css-animations/4-matrix-transforms/prose.md
@@ -0,0 +1,67 @@
+# CSS Matrix Transforms
+
+CSS matrix transforms provide a powerful way to apply complex transformations to elements. The `matrix()` and `matrix3d()` functions allow you to specify a transformation as a matrix of values, giving you precise control over how elements are transformed.
+
+## Understanding the Matrix Transform
+
+The `matrix()` function is a 2D transformation that combines multiple transformations into one. It takes six parameters:
+
+```css
+transform: matrix(a, b, c, d, tx, ty);
+```
+
+These parameters represent a transformation matrix:
+
+```
+| a c tx |
+| b d ty |
+| 0 0 1 |
+```
+
+Where:
+- `a` and `d` control scaling
+- `b` and `c` control skewing
+- `tx` and `ty` control translation (moving)
+
+For 3D transformations, you can use `matrix3d()` which takes 16 parameters representing a 4x4 matrix.
+
+## Common Matrix Transformations
+
+Here are some common transformations expressed as matrices:
+
+1. **Identity (no transformation)**:
+ ```css
+ transform: matrix(1, 0, 0, 1, 0, 0);
+ ```
+
+2. **Scale by 2x**:
+ ```css
+ transform: matrix(2, 0, 0, 2, 0, 0);
+ ```
+
+3. **Rotate by 45 degrees**:
+ ```css
+ transform: matrix(0.7071, 0.7071, -0.7071, 0.7071, 0, 0);
+ ```
+
+4. **Skew horizontally by 30 degrees**:
+ ```css
+ transform: matrix(1, 0, 0.5774, 1, 0, 0);
+ ```
+
+
+ Complete the MatrixTransforms component by implementing the missing matrix transformations:
+
+
Add a matrix transform that combines rotation and scaling
+
Add a matrix transform that creates a perspective effect
+
Add a matrix transform that creates a reflection effect
+
+
diff --git a/apps/tutorial/public/docs/7-form-data/2-on-change/prose.md b/apps/tutorial/public/docs/7-form-data/2-on-change/prose.md
new file mode 100644
index 000000000..4ce84c995
--- /dev/null
+++ b/apps/tutorial/public/docs/7-form-data/2-on-change/prose.md
@@ -0,0 +1,81 @@
+# Form Input Change Events in Ember
+
+When working with forms in Ember, it's important to understand how to handle input change events to create responsive and interactive user interfaces. The `on` modifier provides a way to attach event listeners to DOM elements, including form inputs.
+
+## The `on` Modifier
+
+The `on` modifier allows you to attach event listeners to elements in your templates:
+
+```js
+
+```
+
+This attaches an event listener for the `input` event to the input element, which will call the `handleInput` function whenever the input value changes.
+
+## Common Form Events
+
+When working with forms, there are several events you might want to handle:
+
+1. **input**: Fires whenever the value of an input element changes
+2. **change**: Fires when the value is committed (e.g., when focus leaves the input)
+3. **submit**: Fires when a form is submitted
+4. **focusout**: Fires when an element loses focus
+
+## Using FormData
+
+The `FormData` API provides a convenient way to collect form values:
+
+```js
+const handleInput = (event) => {
+ let formData = new FormData(event.currentTarget);
+ let data = Object.fromEntries(formData.entries());
+
+ // data now contains all form field values
+ console.log(data);
+};
+```
+
+## Real-time Form Updates
+
+To create a form that updates in real-time as the user types, you can use the `input` event:
+
+```js
+import { on } from '@ember/modifier';
+import { cell } from 'ember-resources';
+
+const state = cell({});
+
+const handleInput = (event) => {
+ let formData = new FormData(event.currentTarget);
+ let data = Object.fromEntries(formData.entries());
+
+ state.current = data;
+};
+
+
+
+
+
{{JSON.stringify(state.current, null, 2)}}
+
+```
+
+
+ Complete the FormChangeEvents component by:
+
+
Implementing event handlers for both 'input' and 'change' events
+
Displaying the form data in real-time as the user types
+
Adding a submit handler that prevents the default form submission
+
Implementing a focusout handler to log when fields lose focus
+
+
+
+[Documentation for the on modifier][ember-on-modifier]
+[Documentation for FormData][mdn-formdata]
+[Documentation for form events][mdn-form-events]
+
+[ember-on-modifier]: https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/#toc_event-handlers
+[mdn-formdata]: https://developer.mozilla.org/en-US/docs/Web/API/FormData
+[mdn-form-events]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event
diff --git a/apps/tutorial/public/docs/7-form-data/3-number-inputs/answer.gjs b/apps/tutorial/public/docs/7-form-data/3-number-inputs/answer.gjs
new file mode 100644
index 000000000..f66cf5ee1
--- /dev/null
+++ b/apps/tutorial/public/docs/7-form-data/3-number-inputs/answer.gjs
@@ -0,0 +1,157 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+
+class NumberInputDemo extends Component {
+ @tracked value = 5;
+ @tracked error = null;
+
+ // Minimum and maximum allowed values
+ minValue = 0;
+ maxValue = 10;
+
+ @action
+ updateValue(event) {
+ // Get the value from the input and convert it to a number
+ const inputValue = event.target.value;
+ const numericValue = parseInt(inputValue, 10);
+
+ // Validate the value
+ if (isNaN(numericValue)) {
+ this.error = 'Please enter a valid number';
+ return;
+ }
+
+ if (numericValue < this.minValue) {
+ this.error = `Value cannot be less than ${this.minValue}`;
+ this.value = this.minValue;
+ return;
+ }
+
+ if (numericValue > this.maxValue) {
+ this.error = `Value cannot be greater than ${this.maxValue}`;
+ this.value = this.maxValue;
+ return;
+ }
+
+ // If we get here, the value is valid
+ this.error = null;
+ this.value = numericValue;
+ }
+
+
+
+
Number Input Demo
+
+
+
+
+
+
+
+ {{#if this.error}}
+
{{this.error}}
+ {{/if}}
+
+
+
Current value: {{this.value}}
+
Value type: {{typeof this.value}}
+
+
+
+
FormData Example
+
+
+
+
+
+
+
+ @action
+ handleSubmit(event) {
+ event.preventDefault();
+ const formData = new FormData(event.target);
+ const data = Object.fromEntries(formData.entries());
+
+ console.log('Form data:', data);
+ console.log('Quantity type:', typeof data.quantity); // Will be "string"
+ console.log('Quantity value:', data.quantity);
+
+ // Convert to number if needed
+ const numericQuantity = parseInt(data.quantity, 10);
+ console.log('Numeric quantity:', numericQuantity);
+ console.log('Numeric quantity type:', typeof numericQuantity); // Will be "number"
+ }
+}
+
+
+
Number Input Example
+
+
diff --git a/apps/tutorial/public/docs/7-form-data/3-number-inputs/prompt.gjs b/apps/tutorial/public/docs/7-form-data/3-number-inputs/prompt.gjs
new file mode 100644
index 000000000..d5ee0dac6
--- /dev/null
+++ b/apps/tutorial/public/docs/7-form-data/3-number-inputs/prompt.gjs
@@ -0,0 +1,79 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+
+class NumberInputDemo extends Component {
+ @tracked value = 5;
+ @tracked error = null;
+
+ // Minimum and maximum allowed values
+ minValue = 0;
+ maxValue = 10;
+
+ // TODO: Implement the update handler to convert the string value to a number
+ // and validate that it's within the allowed range
+
+
+
+
Number Input Demo
+
+
+
+
+ {{! TODO: Add a controlled number input with min, max, and step attributes }}
+
+
+
+ {{#if this.error}}
+
{{this.error}}
+ {{/if}}
+
+
+
Current value: {{this.value}}
+
Value type: {{typeof this.value}}
+
+
+
+
+
+}
+
+
+
Number Input Example
+
+
diff --git a/apps/tutorial/public/docs/7-form-data/3-number-inputs/prose.md b/apps/tutorial/public/docs/7-form-data/3-number-inputs/prose.md
new file mode 100644
index 000000000..27d03c9c6
--- /dev/null
+++ b/apps/tutorial/public/docs/7-form-data/3-number-inputs/prose.md
@@ -0,0 +1,54 @@
+# Number Inputs in Ember
+
+Number inputs are HTML input elements with `type="number"`. They allow users to enter numeric values and provide built-in validation and UI controls for incrementing and decrementing the value.
+
+## Working with Number Inputs
+
+When working with number inputs in Ember, it's important to understand that:
+
+1. The value from a number input is always a string, even though it represents a number
+2. You often need to convert this string to a number using `parseInt()` or `parseFloat()`
+3. You can set min, max, and step attributes to control the input behavior
+
+## Controlled Number Input
+
+A controlled number input component manages the input's value through Ember's reactivity system:
+
+```js
+@tracked value = 0;
+
+updateValue = (event) => {
+ this.value = parseInt(event.target.value, 10);
+}
+```
+
+## Using FormData with Number Inputs
+
+When using FormData to collect form values, number inputs are still returned as strings:
+
+```js
+const handleInput = (event) => {
+ let formData = new FormData(event.currentTarget);
+ let data = Object.fromEntries(formData.entries());
+
+ // data.quantity will be a string, not a number
+ console.log(typeof data.quantity); // "string"
+}
+```
+
+
+ Complete the NumberInputDemo component by:
+
+
Adding a controlled number input with min, max, and step attributes
+
Implementing the update handler to properly convert the string value to a number
+
Adding validation to ensure the value stays within bounds
+
+
+
+[Documentation for HTML number input][mdn-number-input]
+[Documentation for parseInt()][mdn-parseint]
+[Documentation for FormData][mdn-formdata]
+
+[mdn-number-input]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/number
+[mdn-parseint]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/parseInt
+[mdn-formdata]: https://developer.mozilla.org/en-US/docs/Web/API/FormData
diff --git a/apps/tutorial/public/docs/7-form-data/4-checkboxes/answer.gjs b/apps/tutorial/public/docs/7-form-data/4-checkboxes/answer.gjs
new file mode 100644
index 000000000..24db3343f
--- /dev/null
+++ b/apps/tutorial/public/docs/7-form-data/4-checkboxes/answer.gjs
@@ -0,0 +1,301 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+
+class CheckboxDemo extends Component {
+ @tracked isSubscribed = false;
+ @tracked selectedFruits = [];
+ @tracked allFruitsSelected = false;
+ @tracked formData = null;
+
+ fruits = [
+ { id: 1, name: 'Apple', value: 'apple' },
+ { id: 2, name: 'Banana', value: 'banana' },
+ { id: 3, name: 'Orange', value: 'orange' },
+ { id: 4, name: 'Strawberry', value: 'strawberry' },
+ { id: 5, name: 'Pineapple', value: 'pineapple' }
+ ];
+
+ @action
+ updateSubscription(event) {
+ this.isSubscribed = event.target.checked;
+ }
+
+ @action
+ updateFruitSelection(event) {
+ const { value, checked } = event.target;
+
+ if (checked) {
+ // Add the fruit to the selected list if it's not already there
+ if (!this.selectedFruits.includes(value)) {
+ this.selectedFruits = [...this.selectedFruits, value];
+ }
+ } else {
+ // Remove the fruit from the selected list
+ this.selectedFruits = this.selectedFruits.filter(fruit => fruit !== value);
+ }
+
+ // Update the "Select All" checkbox state
+ this.allFruitsSelected = this.selectedFruits.length === this.fruits.length;
+ }
+
+ @action
+ toggleAllFruits(event) {
+ const checked = event.target.checked;
+ this.allFruitsSelected = checked;
+
+ if (checked) {
+ // Select all fruits
+ this.selectedFruits = this.fruits.map(fruit => fruit.value);
+ } else {
+ // Deselect all fruits
+ this.selectedFruits = [];
+ }
+ }
+
+ @action
+ handleFormSubmit(event) {
+ event.preventDefault();
+
+ const formData = new FormData(event.target);
+
+ // For single checkboxes, we can use get()
+ const newsletter = formData.get('newsletter') ? true : false;
+
+ // For checkbox groups, we need to use getAll()
+ const selectedFruits = formData.getAll('fruits');
+
+ this.formData = {
+ newsletter,
+ fruits: selectedFruits
+ };
+
+ console.log('Form submitted:', this.formData);
+ }
+
+ isFruitSelected(value) {
+ return this.selectedFruits.includes(value);
+ }
+
+
+
+
+
diff --git a/apps/tutorial/public/docs/7-form-data/4-checkboxes/prose.md b/apps/tutorial/public/docs/7-form-data/4-checkboxes/prose.md
new file mode 100644
index 000000000..9b1f64d83
--- /dev/null
+++ b/apps/tutorial/public/docs/7-form-data/4-checkboxes/prose.md
@@ -0,0 +1,74 @@
+# Checkboxes in Ember
+
+Checkboxes are HTML input elements with `type="checkbox"`. They allow users to select one or more options from a set, making them essential for collecting boolean values or multiple selections in forms.
+
+## Working with Checkboxes
+
+When working with checkboxes in Ember, it's important to understand:
+
+1. Checkboxes have a `checked` property rather than a `value` property
+2. The `value` attribute determines what value is submitted with the form
+3. Unchecked checkboxes don't appear in form data at all
+
+## Controlled Checkbox Components
+
+A controlled checkbox component manages the checkbox's state through Ember's reactivity system:
+
+```js
+@tracked isChecked = false;
+
+updateCheckbox = (event) => {
+ this.isChecked = event.target.checked;
+}
+```
+
+## Using FormData with Checkboxes
+
+When using FormData to collect form values, checkboxes behave differently than other inputs:
+
+```js
+const handleInput = (event) => {
+ let formData = new FormData(event.currentTarget);
+ let data = Object.fromEntries(formData.entries());
+
+ // If the checkbox is unchecked, it won't appear in the data object at all
+ console.log(data); // { otherField: 'value' } (no checkbox field if unchecked)
+
+ // If the checkbox is checked, it will include the value attribute
+ console.log(data); // { checkboxField: 'yes', otherField: 'value' }
+}
+```
+
+## Checkbox Groups
+
+For multiple related checkboxes, you can use the same `name` attribute with different values:
+
+```html
+
+
+
+```
+
+When using FormData, you'll need to use `getAll()` to retrieve all selected values:
+
+```js
+const selectedFruits = formData.getAll('fruits'); // ['apple', 'orange']
+```
+
+
+ Complete the CheckboxDemo component by:
+
+
Implementing a controlled checkbox that updates its state when clicked
+
Creating a checkbox group with multiple options
+
Displaying the selected values from both the single checkbox and the checkbox group
+
Implementing a "Select All" checkbox that toggles all checkboxes in the group
+
+
+
+[Documentation for HTML checkbox input][mdn-checkbox]
+[Documentation for FormData][mdn-formdata]
+[Documentation for FormData.getAll()][mdn-formdata-getall]
+
+[mdn-checkbox]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox
+[mdn-formdata]: https://developer.mozilla.org/en-US/docs/Web/API/FormData
+[mdn-formdata-getall]: https://developer.mozilla.org/en-US/docs/Web/API/FormData/getAll
diff --git a/apps/tutorial/public/docs/7-form-data/contenteditable/answer.gjs b/apps/tutorial/public/docs/7-form-data/contenteditable/answer.gjs
new file mode 100644
index 000000000..d794466a4
--- /dev/null
+++ b/apps/tutorial/public/docs/7-form-data/contenteditable/answer.gjs
@@ -0,0 +1,309 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+import { modifier } from 'ember-modifier';
+import { htmlSafe } from '@ember/template';
+
+class ContenteditableDemo extends Component {
+ @tracked content = '
This is editable content. Try formatting it!
';
+ @tracked formData = null;
+
+ @action
+ updateContent(event) {
+ this.content = event.target.innerHTML;
+ }
+
+ @action
+ formatText(command) {
+ document.execCommand(command, false, null);
+ }
+
+ @action
+ handleFormSubmit(event) {
+ event.preventDefault();
+
+ const formData = new FormData(event.target);
+
+ // Add the contenteditable content to the form data
+ formData.append('richContent', this.content);
+
+ const data = Object.fromEntries(formData.entries());
+ this.formData = data;
+
+ console.log('Form submitted:', this.formData);
+ }
+
+ // Modifier to set initial content and focus the editor
+ contentEditableModifier = modifier((element) => {
+ // Set initial content
+ element.innerHTML = this.content;
+
+ // Focus the editor when it's first rendered
+ setTimeout(() => {
+ element.focus();
+ }, 100);
+ });
+
+ get safeContent() {
+ return htmlSafe(this.content);
+ }
+
+
+
+
Contenteditable Demo
+
+
+
Rich Text Editor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
HTML Preview:
+
{{this.content}}
+
+
+
+
+
Form Integration
+
+
+
+
+ {{#if this.formData}}
+
Form data:
+
{{JSON.stringify this.formData null 2}}
+ {{else}}
+
Form data will appear here after submission
+ {{/if}}
+
+
+
+
+
How It Works
+
This component demonstrates three key concepts with contenteditable elements:
+
+
+
+ Controlled Contenteditable: The rich editor uses a tracked property and an event handler to maintain its state:
+
+ Form Integration: The form example manually adds the contenteditable content to the FormData:
+
+const formData = new FormData(event.target);
+
+// Add the contenteditable content to the form data
+formData.append('richContent', this.content);
+
+const data = Object.fromEntries(formData.entries());
+
+
+
+
+
+
+
+
+}
+
+
+
Contenteditable in Ember
+
+
diff --git a/apps/tutorial/public/docs/7-form-data/contenteditable/prompt.gjs b/apps/tutorial/public/docs/7-form-data/contenteditable/prompt.gjs
new file mode 100644
index 000000000..80820d750
--- /dev/null
+++ b/apps/tutorial/public/docs/7-form-data/contenteditable/prompt.gjs
@@ -0,0 +1,162 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+import { modifier } from 'ember-modifier';
+
+class ContenteditableDemo extends Component {
+ @tracked content = '
This is editable content. Try formatting it!
';
+ @tracked formData = null;
+
+ // TODO: Implement the update handler for the contenteditable element
+
+ // TODO: Implement formatting actions (bold, italic, underline)
+
+ // TODO: Implement the form submission handler
+
+
+
+
Contenteditable Demo
+
+
+
Rich Text Editor
+
+
+ {{! TODO: Add formatting buttons }}
+
+
+
+ {{! TODO: Add a contenteditable element }}
+
+
+
+
HTML Preview:
+
{{this.content}}
+
+
+
+
+
Form Integration
+
+
+
+
+ {{#if this.formData}}
+
Form data:
+
{{JSON.stringify this.formData null 2}}
+ {{else}}
+
Form data will appear here after submission
+ {{/if}}
+
+
+
+
+
+
+}
+
+
+
Contenteditable in Ember
+
+
diff --git a/apps/tutorial/public/docs/7-form-data/contenteditable/prose.md b/apps/tutorial/public/docs/7-form-data/contenteditable/prose.md
new file mode 100644
index 000000000..f4fae6c05
--- /dev/null
+++ b/apps/tutorial/public/docs/7-form-data/contenteditable/prose.md
@@ -0,0 +1,74 @@
+# Contenteditable Elements in Ember
+
+The `contenteditable` attribute allows users to edit the content of an HTML element directly in the browser. This provides a rich editing experience without requiring a traditional form input like a textarea.
+
+## Working with Contenteditable
+
+When working with contenteditable elements in Ember, it's important to understand:
+
+1. Contenteditable elements don't have a `value` property like form inputs
+2. You need to use the element's `innerHTML` or `textContent` to get or set the content
+3. Contenteditable elements don't automatically participate in form submissions
+4. You need to handle events like `input` to track changes
+
+## Controlled Contenteditable Components
+
+A controlled contenteditable component manages the element's content through Ember's reactivity system:
+
+```js
+@tracked content = 'Initial content';
+
+updateContent = (event) => {
+ this.content = event.target.innerHTML;
+}
+```
+
+## Handling Rich Content
+
+Contenteditable elements can contain rich HTML content, including formatting:
+
+```html
+
+
This is rich content with formatting.
+
+```
+
+To preserve this formatting when updating the content, you should use `innerHTML` rather than `textContent`.
+
+## Integrating with Forms
+
+Since contenteditable elements don't participate in form submissions, you need to manually include their content:
+
+```js
+const handleSubmit = (event) => {
+ event.preventDefault();
+
+ const formData = new FormData(event.target);
+
+ // Add the contenteditable content to the form data
+ const editorContent = document.querySelector('.rich-editor').innerHTML;
+ formData.append('richContent', editorContent);
+
+ // Process the form data
+ const data = Object.fromEntries(formData.entries());
+ console.log(data);
+}
+```
+
+
+ Complete the ContenteditableDemo component by:
+
+
Implementing a controlled contenteditable element that updates its state when the content changes
+
Creating a simple toolbar to add basic formatting (bold, italic, underline)
+
Displaying a preview of the HTML content
+
Implementing a form that includes the contenteditable content on submission
+
+
diff --git a/apps/tutorial/public/docs/7-form-data/file-inputs/prompt.gjs b/apps/tutorial/public/docs/7-form-data/file-inputs/prompt.gjs
new file mode 100644
index 000000000..6c049ccba
--- /dev/null
+++ b/apps/tutorial/public/docs/7-form-data/file-inputs/prompt.gjs
@@ -0,0 +1,195 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+
+class FileInputDemo extends Component {
+ @tracked selectedFiles = [];
+ @tracked imagePreview = null;
+ @tracked formData = null;
+
+ // TODO: Implement the update handler for the file input
+
+ // TODO: Implement a method to read and preview image files
+
+ // TODO: Implement the form submission handler
+
+
+
+
File Input Demo
+
+
+
Basic File Input
+
+
+
+
+ {{! TODO: Add a file input that updates the selectedFiles state }}
+
+
Select one or more files to see their details
+
+
+
+
Selected Files:
+
+ {{#if this.selectedFiles.length}}
+
+ {{! TODO: Display information about each selected file }}
+
+ {{else}}
+
No files selected
+ {{/if}}
+
+
+
+
+
Image Preview
+
+
+
+
+ {{! TODO: Add a file input that only accepts images and shows a preview }}
+
+
Select an image file to see a preview
+
+
+
+ {{#if this.imagePreview}}
+
+ {{else}}
+
Image preview will appear here
+ {{/if}}
+
+
+
+
+
Form Data Example
+
+
+
+
+ {{#if this.formData}}
+
Form data:
+
{{this.formData}}
+ {{else}}
+
Form data will appear here after submission
+ {{/if}}
+
+
+
+
+
+
+}
+
+
+
File Inputs in Ember
+
+
diff --git a/apps/tutorial/public/docs/7-form-data/file-inputs/prose.md b/apps/tutorial/public/docs/7-form-data/file-inputs/prose.md
new file mode 100644
index 000000000..1d20f40df
--- /dev/null
+++ b/apps/tutorial/public/docs/7-form-data/file-inputs/prose.md
@@ -0,0 +1,87 @@
+# File Inputs in Ember
+
+File inputs allow users to upload files from their local device to a web application. In Ember, working with file inputs requires understanding how to handle file objects and their properties.
+
+## Working with File Inputs
+
+When working with file inputs in Ember, it's important to understand:
+
+1. File inputs have a `files` property that contains a FileList object
+2. The FileList is an array-like object containing File objects
+3. File objects have properties like `name`, `size`, `type`, and `lastModified`
+4. You can use the FileReader API to read the contents of files
+
+## Controlled File Input Components
+
+A controlled file input component manages the selected files through Ember's reactivity system:
+
+```js
+@tracked selectedFiles = [];
+
+updateFiles = (event) => {
+ // Convert the FileList to an array
+ this.selectedFiles = Array.from(event.target.files);
+}
+```
+
+## Using FormData with File Inputs
+
+When using FormData to collect form values with file inputs:
+
+```js
+const handleSubmit = (event) => {
+ let formData = new FormData(event.currentTarget);
+
+ // FormData automatically includes file inputs
+ // You can access the file using the input's name
+ const file = formData.get('avatar');
+
+ console.log(file.name, file.size, file.type);
+}
+```
+
+## Reading File Contents
+
+To read the contents of a file, you can use the FileReader API:
+
+```js
+const readFile = (file) => {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+
+ reader.onload = (event) => {
+ resolve(event.target.result);
+ };
+
+ reader.onerror = (error) => {
+ reject(error);
+ };
+
+ // Read as data URL (for images)
+ reader.readAsDataURL(file);
+
+ // Or read as text (for text files)
+ // reader.readAsText(file);
+ });
+}
+```
+
+
+ Complete the FileInputDemo component by:
+
+
Implementing a controlled file input that updates its state when files are selected
+
Displaying information about the selected files (name, size, type)
+
Creating a preview for image files using the FileReader API
+
Implementing a form that includes file inputs and collects the files on submission
+ Organized Form Submission: The form submission handler organizes the flat form data into logical groups:
+
+const formData = new FormData(event.target);
+const flatData = Object.fromEntries(formData.entries());
+
+// Organize the flat data into groups
+const organizedData = {
+ personalInfo: {
+ firstName: flatData.firstName,
+ lastName: flatData.lastName,
+ // ...
+ },
+ // Other groups...
+};
+
+
+
+
+
+
+
+
+}
+
+
+
Form Groups in Ember
+
+
diff --git a/apps/tutorial/public/docs/7-form-data/groups/prompt.gjs b/apps/tutorial/public/docs/7-form-data/groups/prompt.gjs
new file mode 100644
index 000000000..0a3031e2d
--- /dev/null
+++ b/apps/tutorial/public/docs/7-form-data/groups/prompt.gjs
@@ -0,0 +1,133 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+
+class FormGroupsDemo extends Component {
+ // TODO: Create tracked objects for each form group
+
+ // TODO: Implement update handlers for each form group
+
+ // TODO: Implement the form submission handler
+
+
+
+
Form Groups Demo
+
+
+
+
+
+
Form Data Preview
+
+ {{! TODO: Display the current form data }}
+
Form data will appear here as you type
+
+
+
+
+
Submission Result
+
+ {{! TODO: Display the form submission result }}
+
Form submission result will appear here
+
+
+
+
+
+
+
+}
+
+
+
Form Groups in Ember
+
+
diff --git a/apps/tutorial/public/docs/7-form-data/groups/prose.md b/apps/tutorial/public/docs/7-form-data/groups/prose.md
new file mode 100644
index 000000000..a70325bcf
--- /dev/null
+++ b/apps/tutorial/public/docs/7-form-data/groups/prose.md
@@ -0,0 +1,110 @@
+# Form Input Groups in Ember
+
+Form input groups allow you to organize related form controls together, making forms more structured and easier to understand. This is particularly useful for complex forms with multiple sections or logical groupings of inputs.
+
+## Working with Form Input Groups
+
+When working with form input groups in Ember, it's important to understand:
+
+1. HTML provides semantic elements like `fieldset` and `legend` for grouping related inputs
+2. You can use CSS to visually separate and style different form groups
+3. Form groups can help organize data collection and validation logic
+4. When using FormData, inputs within groups are still accessed by their name attributes
+
+## Semantic Form Grouping
+
+HTML provides the `fieldset` and `legend` elements specifically for grouping related form controls:
+
+```html
+
+```
+
+## Organizing Form Data
+
+When working with grouped inputs, you can organize your data collection logic by group:
+
+```js
+@tracked personalInfo = {
+ firstName: '',
+ lastName: '',
+ email: ''
+};
+
+@tracked addressInfo = {
+ street: '',
+ city: '',
+ state: '',
+ zip: ''
+};
+
+updatePersonalInfo = (event) => {
+ const { name, value } = event.target;
+ this.personalInfo = {
+ ...this.personalInfo,
+ [name]: value
+ };
+}
+
+updateAddressInfo = (event) => {
+ const { name, value } = event.target;
+ this.addressInfo = {
+ ...this.addressInfo,
+ [name]: value
+ };
+}
+```
+
+## Using FormData with Grouped Inputs
+
+When using FormData to collect form values, inputs within groups are still accessed by their name attributes:
+
+```js
+const handleSubmit = (event) => {
+ let formData = new FormData(event.currentTarget);
+ let data = Object.fromEntries(formData.entries());
+
+ // You can then organize the flat data into groups if needed
+ const personalInfo = {
+ firstName: data.firstName,
+ lastName: data.lastName,
+ email: data.email
+ };
+
+ const addressInfo = {
+ street: data.street,
+ city: data.city,
+ state: data.state,
+ zip: data.zip
+ };
+
+ console.log({ personalInfo, addressInfo });
+}
+```
+
+
+ Complete the FormGroupsDemo component by:
+
+
Implementing a multi-section form with logical groupings of inputs
+
Creating tracked objects to store data for each form group
+
Adding update handlers that maintain the state of each group
+
Implementing a form submission handler that organizes the data by group
+ FormData API: The form example uses FormData to collect the selected value:
+
+const formData = new FormData(event.target);
+const data = Object.fromEntries(formData.entries());
+
+// data.favoriteColor will be the value of the selected radio
+console.log(data.favoriteColor); // "red", "blue", etc.
+
+
+
+
+
Radio inputs with the same name attribute form a group where only one can be selected at a time. This makes them perfect for mutually exclusive choices.
+
+
For accessibility, we've used:
+
+
A fieldset with a legend to group related radio inputs
+
Proper label elements associated with each input
+
Unique id attributes for each input
+
+
+
+
+
+
+}
+
+
+
Radio Inputs in Ember
+
+
diff --git a/apps/tutorial/public/docs/7-form-data/radio-inputs/prompt.gjs b/apps/tutorial/public/docs/7-form-data/radio-inputs/prompt.gjs
new file mode 100644
index 000000000..d464bbe38
--- /dev/null
+++ b/apps/tutorial/public/docs/7-form-data/radio-inputs/prompt.gjs
@@ -0,0 +1,128 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+
+class RadioInputDemo extends Component {
+ @tracked selectedColor = null;
+ @tracked formData = null;
+
+ colors = [
+ { id: 'red', name: 'Red', hex: '#e74c3c' },
+ { id: 'blue', name: 'Blue', hex: '#3498db' },
+ { id: 'green', name: 'Green', hex: '#2ecc71' },
+ { id: 'purple', name: 'Purple', hex: '#9b59b6' },
+ { id: 'yellow', name: 'Yellow', hex: '#f1c40f' }
+ ];
+
+ // TODO: Implement the update handler for the radio group
+
+ // TODO: Implement the form submission handler
+
+
+
+
Radio Input Demo
+
+
+
Controlled Radio Group
+
+
+
+
+ {{#if this.selectedColor}}
+
You selected: {{this.selectedColor.name}}
+ {{else}}
+
No color selected
+ {{/if}}
+
+
+
+
+
Form Data Example
+
+
+
+
+ {{#if this.formData}}
+
Form data:
+
{{JSON.stringify this.formData null 2}}
+ {{else}}
+
Form data will appear here after submission
+ {{/if}}
+
+
+
+
+
+
+}
+
+
+
Radio Inputs in Ember
+
+
diff --git a/apps/tutorial/public/docs/7-form-data/radio-inputs/prose.md b/apps/tutorial/public/docs/7-form-data/radio-inputs/prose.md
new file mode 100644
index 000000000..d785e5f60
--- /dev/null
+++ b/apps/tutorial/public/docs/7-form-data/radio-inputs/prose.md
@@ -0,0 +1,64 @@
+# Radio Inputs in Ember
+
+Radio inputs are HTML input elements with `type="radio"`. They allow users to select exactly one option from a set of choices, making them ideal for mutually exclusive selections.
+
+## Working with Radio Inputs
+
+When working with radio inputs in Ember, it's important to understand:
+
+1. Radio inputs with the same `name` attribute form a group
+2. Only one radio input in a group can be selected at a time
+3. The `value` attribute determines what value is submitted with the form
+4. The `checked` attribute determines which radio is initially selected
+
+## Controlled Radio Components
+
+A controlled radio component manages the radio group's state through Ember's reactivity system:
+
+```js
+@tracked selectedOption = 'option1';
+
+updateSelection = (event) => {
+ this.selectedOption = event.target.value;
+}
+```
+
+## Using FormData with Radio Inputs
+
+When using FormData to collect form values, only the selected radio input's value is included:
+
+```js
+const handleSubmit = (event) => {
+ let formData = new FormData(event.currentTarget);
+ let data = Object.fromEntries(formData.entries());
+
+ // data.preference will be the value of the selected radio input
+ console.log(data.preference); // "option1"
+}
+```
+
+## Radio Input Accessibility
+
+For better accessibility, always:
+
+1. Group related radio inputs inside a `fieldset` with a descriptive `legend`
+2. Associate each radio input with a `label` using the `for` attribute
+3. Ensure radio inputs can be navigated and selected using the keyboard
+
+
+ Complete the RadioInputDemo component by:
+
+
Implementing a controlled radio group that updates its state when a selection is made
+
Displaying the currently selected option
+
Creating a form that uses radio inputs and collects the selected value on submission
+
Ensuring the radio inputs are accessible with proper labeling and grouping
Hold Ctrl (or Cmd on Mac) to select multiple options
+
+
+
+
Selected fruits:
+ {{#if this.selectedFruits.length}}
+
+ {{#each this.selectedFruits as |fruit|}}
+
{{fruit}}
+ {{/each}}
+
+ {{else}}
+
No fruits selected
+ {{/if}}
+
+
+
+
+
Form Data Example
+
+
+
+
+ {{#if this.formData}}
+
Form data:
+
{{JSON.stringify this.formData null 2}}
+ {{else}}
+
Form data will appear here after submission
+ {{/if}}
+
+
+
+
+
+
+}
+
+
+
Multiple Select in Ember
+
+
diff --git a/apps/tutorial/public/docs/7-form-data/select-multiple/prose.md b/apps/tutorial/public/docs/7-form-data/select-multiple/prose.md
new file mode 100644
index 000000000..6b43eb04c
--- /dev/null
+++ b/apps/tutorial/public/docs/7-form-data/select-multiple/prose.md
@@ -0,0 +1,80 @@
+# Multiple Select Inputs in Ember
+
+Multiple select inputs allow users to select multiple options from a dropdown list. This is useful for scenarios where users need to choose several items from a predefined set of options.
+
+## Working with Multiple Select Inputs
+
+When working with multiple select inputs in Ember, it's important to understand:
+
+1. Add the `multiple` attribute to enable multiple selection
+2. The selected options are available as an array-like object in `event.target.selectedOptions`
+3. You need to convert this to an array to work with it effectively
+4. When using FormData, you need to use `getAll()` to retrieve all selected values
+
+## Controlled Multiple Select Components
+
+A controlled multiple select component manages the selection state through Ember's reactivity system:
+
+```js
+@tracked selectedOptions = [];
+
+updateSelection = (event) => {
+ // Convert the HTMLCollection to an array of values
+ this.selectedOptions = Array.from(event.target.selectedOptions).map(option => option.value);
+}
+```
+
+## Using FormData with Multiple Select
+
+When using FormData to collect form values with multiple select inputs:
+
+```js
+const handleSubmit = (event) => {
+ let formData = new FormData(event.currentTarget);
+
+ // Use getAll() to retrieve all selected values
+ const selectedOptions = formData.getAll('options');
+
+ console.log(selectedOptions); // ['option1', 'option3', 'option4']
+}
+```
+
+## Styling Multiple Select Inputs
+
+Multiple select inputs can be styled to improve usability:
+
+```css
+select[multiple] {
+ min-height: 150px;
+ padding: 0.5rem;
+}
+
+select[multiple] option {
+ padding: 0.5rem;
+ margin-bottom: 0.25rem;
+ border-radius: 4px;
+}
+
+select[multiple] option:checked {
+ background-color: #3498db;
+ color: white;
+}
+```
+
+
+ Complete the MultiSelectDemo component by:
+
+
Implementing a controlled multiple select that updates its state when selections change
+
Displaying the currently selected options
+
Creating a form that uses a multiple select and collects the selected values on submission
+
Adding custom styling to improve the usability of the multiple select
+
+
+
+[Documentation for HTML select element with multiple attribute][mdn-select-multiple]
+[Documentation for FormData][mdn-formdata]
+[Documentation for FormData.getAll()][mdn-formdata-getall]
+
+[mdn-select-multiple]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select#attr-multiple
+[mdn-formdata]: https://developer.mozilla.org/en-US/docs/Web/API/FormData
+[mdn-formdata-getall]: https://developer.mozilla.org/en-US/docs/Web/API/FormData/getAll
diff --git a/apps/tutorial/public/docs/7-form-data/textarea/answer.gjs b/apps/tutorial/public/docs/7-form-data/textarea/answer.gjs
new file mode 100644
index 000000000..a78265768
--- /dev/null
+++ b/apps/tutorial/public/docs/7-form-data/textarea/answer.gjs
@@ -0,0 +1,344 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+import { modifier } from 'ember-modifier';
+
+class TextareaDemo extends Component {
+ @tracked content = '';
+ @tracked formData = null;
+ @tracked characterCount = 0;
+ @tracked autoResizeContent = '';
+
+ maxLength = 280; // Maximum character limit
+
+ @action
+ updateContent(event) {
+ const value = event.target.value;
+
+ // Enforce the character limit
+ if (value.length <= this.maxLength) {
+ this.content = value;
+ this.characterCount = value.length;
+ } else {
+ // If the user tries to paste text that exceeds the limit, truncate it
+ this.content = value.slice(0, this.maxLength);
+ this.characterCount = this.maxLength;
+ event.target.value = this.content;
+ }
+ }
+
+ @action
+ updateAutoResizeContent(event) {
+ this.autoResizeContent = event.target.value;
+ }
+
+ // Modifier for auto-resizing the textarea
+ autoResize = modifier((element) => {
+ // Set the initial height
+ this.adjustHeight(element);
+
+ // Add an input event listener to adjust height as the user types
+ const handleInput = () => {
+ this.adjustHeight(element);
+ };
+
+ element.addEventListener('input', handleInput);
+
+ // Return a cleanup function
+ return () => {
+ element.removeEventListener('input', handleInput);
+ };
+ });
+
+ adjustHeight(element) {
+ // Reset the height to auto to get the correct scrollHeight
+ element.style.height = 'auto';
+ // Set the height to the scrollHeight to fit the content
+ element.style.height = `${element.scrollHeight}px`;
+ }
+
+ @action
+ handleFormSubmit(event) {
+ event.preventDefault();
+
+ const formData = new FormData(event.target);
+ const data = Object.fromEntries(formData.entries());
+
+ this.formData = data;
+ console.log('Form submitted:', this.formData);
+ }
+
+ get remainingCharacters() {
+ return this.maxLength - this.characterCount;
+ }
+
+
+
+ FormData API: The form example uses FormData to collect values:
+
+const formData = new FormData(event.target);
+const data = Object.fromEntries(formData.entries());
+
+// data.feedback will contain the textarea content
+console.log(data.feedback);
+
+
+
+
+
+
+
+
+}
+
+
+
Textareas in Ember
+
+
diff --git a/apps/tutorial/public/docs/7-form-data/textarea/prompt.gjs b/apps/tutorial/public/docs/7-form-data/textarea/prompt.gjs
new file mode 100644
index 000000000..4fff6f444
--- /dev/null
+++ b/apps/tutorial/public/docs/7-form-data/textarea/prompt.gjs
@@ -0,0 +1,168 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+import { modifier } from 'ember-modifier';
+
+class TextareaDemo extends Component {
+ @tracked content = '';
+ @tracked formData = null;
+ @tracked characterCount = 0;
+
+ maxLength = 280; // Maximum character limit
+
+ // TODO: Implement the update handler for the textarea
+
+ // TODO: Implement a modifier for auto-resizing the textarea
+
+ // TODO: Implement the form submission handler
+
+
+
+
+
diff --git a/apps/tutorial/public/docs/7-form-data/textarea/prose.md b/apps/tutorial/public/docs/7-form-data/textarea/prose.md
new file mode 100644
index 000000000..dd9e21cf0
--- /dev/null
+++ b/apps/tutorial/public/docs/7-form-data/textarea/prose.md
@@ -0,0 +1,65 @@
+# Textarea Inputs in Ember
+
+Textarea elements allow users to input multiple lines of text, making them ideal for comments, descriptions, and other longer-form content. In Ember, textareas can be controlled components just like other form inputs.
+
+## Working with Textareas
+
+When working with textareas in Ember, it's important to understand:
+
+1. Textareas use their content between opening and closing tags as their initial value
+2. For controlled components, you should use the `value` attribute instead
+3. Textareas can be resized by users by default, but this can be controlled with CSS
+
+## Controlled Textarea Components
+
+A controlled textarea component manages the textarea's value through Ember's reactivity system:
+
+```js
+@tracked content = '';
+
+updateContent = (event) => {
+ this.content = event.target.value;
+}
+```
+
+## Using FormData with Textareas
+
+When using FormData to collect form values, textareas work just like other inputs:
+
+```js
+const handleSubmit = (event) => {
+ let formData = new FormData(event.currentTarget);
+ let data = Object.fromEntries(formData.entries());
+
+ // data.comments will contain the textarea content
+ console.log(data.comments);
+}
+```
+
+## Textarea Features
+
+Textareas support several attributes that can enhance the user experience:
+
+- `rows` and `cols` to set the initial size
+- `minlength` and `maxlength` to limit the text length
+- `placeholder` to provide a hint
+- `readonly` to prevent editing
+- `required` to make the field mandatory
+
+
+ Complete the TextareaDemo component by:
+
+
Implementing a controlled textarea that updates its state when the content changes
+
Adding character counting functionality to show remaining characters
+
Implementing auto-resize functionality to grow the textarea with content
+
Creating a form that collects textarea content on submission
+
+
+
+[Documentation for HTML textarea element][mdn-textarea]
+[Documentation for FormData][mdn-formdata]
+[Documentation for auto-resizing textareas][css-tricks-auto-resize]
+
+[mdn-textarea]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea
+[mdn-formdata]: https://developer.mozilla.org/en-US/docs/Web/API/FormData
+[css-tricks-auto-resize]: https://css-tricks.com/the-cleanest-trick-for-autogrowing-textareas/
diff --git a/apps/tutorial/public/docs/x-10-observation/1-element-dimensions/answer.gjs b/apps/tutorial/public/docs/x-10-observation/1-element-dimensions/answer.gjs
new file mode 100644
index 000000000..ffeef5369
--- /dev/null
+++ b/apps/tutorial/public/docs/x-10-observation/1-element-dimensions/answer.gjs
@@ -0,0 +1,176 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { modifier } from 'ember-modifier';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+
+class ElementDimensions extends Component {
+ @tracked width = 0;
+ @tracked height = 0;
+ @tracked isResizing = false;
+
+ resizeObserver = modifier((element) => {
+ // Create a ResizeObserver
+ const observer = new ResizeObserver(entries => {
+ for (const entry of entries) {
+ // Update the component's tracked properties with the new dimensions
+ // Using Math.round to avoid decimal values
+ this.width = Math.round(entry.contentRect.width);
+ this.height = Math.round(entry.contentRect.height);
+ }
+ });
+
+ // Start observing the element
+ observer.observe(element);
+
+ // Initialize the dimensions
+ this.width = Math.round(element.offsetWidth);
+ this.height = Math.round(element.offsetHeight);
+
+ // Return a cleanup function
+ return () => {
+ // Clean up the observer
+ observer.disconnect();
+ };
+ });
+
+ @action
+ toggleResizing() {
+ this.isResizing = !this.isResizing;
+ }
+
+
+
+
Element Dimensions Observer
+
+
+
+
+
+
+
+
Width: {{this.width}}px
+
Height: {{this.height}}px
+
+
+
+
+
{{if this.isResizing "The box is now animated to change size. Watch the dimensions update in real-time!" "Click the button to start the resizing animation."}}
+
+
+
+
How It Works
+
This component uses the ResizeObserver API to track changes to the element's dimensions:
+
+const observer = new ResizeObserver(entries => {
+ for (const entry of entries) {
+ this.width = Math.round(entry.contentRect.width);
+ this.height = Math.round(entry.contentRect.height);
+ }
+});
+
+observer.observe(element);
+
+// Cleanup when the component is destroyed
+return () => {
+ observer.disconnect();
+};
+
+
+
+
+
+
+}
+
+
+
Observing Element Dimensions
+
+
diff --git a/apps/tutorial/public/docs/x-10-observation/1-element-dimensions/prompt.gjs b/apps/tutorial/public/docs/x-10-observation/1-element-dimensions/prompt.gjs
new file mode 100644
index 000000000..ef6c72763
--- /dev/null
+++ b/apps/tutorial/public/docs/x-10-observation/1-element-dimensions/prompt.gjs
@@ -0,0 +1,129 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { modifier } from 'ember-modifier';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+
+class ElementDimensions extends Component {
+ @tracked width = 0;
+ @tracked height = 0;
+ @tracked isResizing = false;
+
+ // TODO: Implement a resize observer modifier that updates width and height
+ resizeObserver = modifier((element) => {
+ // Create a ResizeObserver
+
+ // Start observing the element
+
+ // Return a cleanup function
+ return () => {
+ // Clean up the observer
+ };
+ });
+
+ @action
+ toggleResizing() {
+ this.isResizing = !this.isResizing;
+ }
+
+
+
+
Element Dimensions Observer
+
+
+
+
+
+
+
+
Width: {{this.width}}px
+
Height: {{this.height}}px
+
+
+
+
+
{{if this.isResizing "The box is now animated to change size. Watch the dimensions update in real-time!" "Click the button to start the resizing animation."}}
+
+
+
+
+
+}
+
+
+
Observing Element Dimensions
+
+
diff --git a/apps/tutorial/public/docs/x-10-observation/1-element-dimensions/prose.md b/apps/tutorial/public/docs/x-10-observation/1-element-dimensions/prose.md
new file mode 100644
index 000000000..7a07fc4b3
--- /dev/null
+++ b/apps/tutorial/public/docs/x-10-observation/1-element-dimensions/prose.md
@@ -0,0 +1,78 @@
+# Observing Element Dimensions
+
+Modern web applications often need to respond to changes in element dimensions. The platform provides the [ResizeObserver API](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) for this purpose, allowing you to observe changes to an element's size.
+
+## ResizeObserver API
+
+The ResizeObserver API provides a way to observe changes to the dimensions of an Element's content or border box, or the bounding box of an SVGElement.
+
+```js
+// Create a new ResizeObserver
+const resizeObserver = new ResizeObserver(entries => {
+ for (const entry of entries) {
+ // Handle resize
+ console.log('Element resized:', entry.contentRect);
+ }
+});
+
+// Start observing an element
+resizeObserver.observe(element);
+
+// Stop observing
+resizeObserver.unobserve(element);
+
+// Disconnect (stop observing all elements)
+resizeObserver.disconnect();
+```
+
+## Using ResizeObserver in Ember Components
+
+In Ember components, you can use the ResizeObserver in the `didInsertElement` lifecycle hook for classic components or in a modifier for Glimmer components:
+
+```js
+import Component from '@glimmer/component';
+import { modifier } from 'ember-modifier';
+
+export default class ResizeAwareComponent extends Component {
+ resizeObserver = modifier((element) => {
+ const observer = new ResizeObserver(entries => {
+ for (const entry of entries) {
+ this.handleResize(entry);
+ }
+ });
+
+ observer.observe(element);
+
+ return () => {
+ observer.disconnect();
+ };
+ });
+
+ handleResize(entry) {
+ // Handle the resize event
+ const { width, height } = entry.contentRect;
+ console.log(`Element size: ${width} x ${height}`);
+ }
+
+
+
+ Resize me!
+
+
+}
+```
+
+
+ Complete the ElementDimensions component by implementing the resize observer modifier:
+
+
Create a modifier that uses ResizeObserver to track element dimensions
+
Update the component state with the current dimensions
+
Make sure to clean up the observer when the component is destroyed
+
+
+
+[Documentation for ResizeObserver][mdn-resize-observer]
+[Documentation for Ember Modifiers][ember-modifiers]
+
+[mdn-resize-observer]: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver
+[ember-modifiers]: https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/#toc_custom-modifiers
diff --git a/apps/tutorial/public/docs/x-10-observation/2-dom-mutations/answer.gjs b/apps/tutorial/public/docs/x-10-observation/2-dom-mutations/answer.gjs
new file mode 100644
index 000000000..0f3045933
--- /dev/null
+++ b/apps/tutorial/public/docs/x-10-observation/2-dom-mutations/answer.gjs
@@ -0,0 +1,281 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { modifier } from 'ember-modifier';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+
+class DomMutations extends Component {
+ @tracked mutations = [];
+ @tracked maxMutations = 10;
+ @tracked childCount = 0;
+
+ mutationObserver = modifier((element) => {
+ // Create a MutationObserver
+ const observer = new MutationObserver(mutations => {
+ for (const mutation of mutations) {
+ this.handleMutation(mutation);
+ }
+ });
+
+ // Configure and start observing the element
+ observer.observe(element, {
+ childList: true, // Observe direct children
+ attributes: true, // Observe attributes
+ characterData: true, // Observe text content
+ subtree: true // Observe all descendants
+ });
+
+ // Return a cleanup function
+ return () => {
+ // Clean up the observer
+ observer.disconnect();
+ };
+ });
+
+ handleMutation(mutation) {
+ let mutationInfo = '';
+
+ // Format the mutation information based on its type
+ if (mutation.type === 'childList') {
+ if (mutation.addedNodes.length > 0) {
+ mutationInfo = `Added ${mutation.addedNodes.length} node(s) to ${this.getElementDescription(mutation.target)}`;
+ } else if (mutation.removedNodes.length > 0) {
+ mutationInfo = `Removed ${mutation.removedNodes.length} node(s) from ${this.getElementDescription(mutation.target)}`;
+ }
+ } else if (mutation.type === 'attributes') {
+ mutationInfo = `Changed attribute '${mutation.attributeName}' on ${this.getElementDescription(mutation.target)}`;
+ } else if (mutation.type === 'characterData') {
+ mutationInfo = `Changed text content in ${this.getElementDescription(mutation.target.parentNode)}`;
+ }
+
+ // Add the mutation to the log
+ this.addMutationToLog(mutationInfo);
+ }
+
+ getElementDescription(element) {
+ if (element.id) {
+ return `#${element.id}`;
+ } else if (element.className) {
+ return `.${element.className.replace(/\s+/g, '.')}`;
+ } else {
+ return element.tagName.toLowerCase();
+ }
+ }
+
+ addMutationToLog(mutationInfo) {
+ // Add the mutation to the beginning of the array
+ this.mutations = [mutationInfo, ...this.mutations];
+
+ // Limit the number of mutations in the log
+ if (this.mutations.length > this.maxMutations) {
+ this.mutations = this.mutations.slice(0, this.maxMutations);
+ }
+ }
+
+ @action
+ addElement() {
+ const container = document.getElementById('children-container');
+ if (container) {
+ this.childCount++;
+ const newElement = document.createElement('div');
+ newElement.className = 'child-element';
+ newElement.textContent = `Child Element ${this.childCount}`;
+ container.appendChild(newElement);
+ }
+ }
+
+ @action
+ removeElement() {
+ const container = document.getElementById('children-container');
+ if (container && container.children.length > 0) {
+ container.removeChild(container.lastChild);
+ }
+ }
+
+ @action
+ changeAttribute() {
+ const element = document.getElementById('attribute-element');
+ if (element) {
+ // Toggle between normal and highlighted classes
+ if (element.className === 'normal') {
+ element.className = 'highlighted';
+ } else {
+ element.className = 'normal';
+ }
+ }
+ }
+
+ @action
+ changeText() {
+ const element = document.getElementById('text-element');
+ if (element) {
+ // Toggle between two different text contents
+ if (element.textContent === 'This is a text element that can be modified') {
+ element.textContent = 'The text content has been changed!';
+ } else {
+ element.textContent = 'This is a text element that can be modified';
+ }
+ }
+ }
+
+ @action
+ clearMutations() {
+ this.mutations = [];
+ }
+
+
+
+
DOM Mutations Observer
+
+
+
+
+
+
+
+
+
+
+
This is a text element that can be modified
+
This element's attributes can change
+
+
+
+
+
+
+
Mutations Log
+ {{#if this.mutations.length}}
+
+ {{#each this.mutations as |mutation|}}
+
{{mutation}}
+ {{/each}}
+
+ {{else}}
+
No mutations detected yet. Try using the controls above.
+ {{/if}}
+
+
+
+
How It Works
+
This component uses the MutationObserver API to track changes to the DOM:
+
+const observer = new MutationObserver(mutations => {
+ for (const mutation of mutations) {
+ this.handleMutation(mutation);
+ }
+});
+
+observer.observe(element, {
+ childList: true, // Observe direct children
+ attributes: true, // Observe attributes
+ characterData: true, // Observe text content
+ subtree: true // Observe all descendants
+});
+
+// Cleanup when the component is destroyed
+return () => {
+ observer.disconnect();
+};
+
+
+
+
+
+
+}
+
+
+
Observing DOM Mutations
+
+
diff --git a/apps/tutorial/public/docs/x-10-observation/2-dom-mutations/prompt.gjs b/apps/tutorial/public/docs/x-10-observation/2-dom-mutations/prompt.gjs
new file mode 100644
index 000000000..2c45d4f55
--- /dev/null
+++ b/apps/tutorial/public/docs/x-10-observation/2-dom-mutations/prompt.gjs
@@ -0,0 +1,164 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { modifier } from 'ember-modifier';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+
+class DomMutations extends Component {
+ @tracked mutations = [];
+ @tracked maxMutations = 10;
+
+ // TODO: Implement a mutation observer modifier that tracks DOM changes
+ mutationObserver = modifier((element) => {
+ // Create a MutationObserver
+
+ // Configure and start observing the element
+
+ // Return a cleanup function
+ return () => {
+ // Clean up the observer
+ };
+ });
+
+ @action
+ addElement() {
+ // This will be implemented to add a new element to the observed container
+ }
+
+ @action
+ removeElement() {
+ // This will be implemented to remove an element from the observed container
+ }
+
+ @action
+ changeAttribute() {
+ // This will be implemented to change an attribute on an element in the observed container
+ }
+
+ @action
+ changeText() {
+ // This will be implemented to change text content in the observed container
+ }
+
+ @action
+ clearMutations() {
+ this.mutations = [];
+ }
+
+
+
+
DOM Mutations Observer
+
+
+
+
+
+
+
+
+
+
+
This is a text element that can be modified
+
This element's attributes can change
+
+
+
+
+
+
+
Mutations Log
+ {{#if this.mutations.length}}
+
+ {{#each this.mutations as |mutation|}}
+
{{mutation}}
+ {{/each}}
+
+ {{else}}
+
No mutations detected yet. Try using the controls above.
+ {{/if}}
+
+
+
+
+
+}
+
+
+
Observing DOM Mutations
+
+
diff --git a/apps/tutorial/public/docs/x-10-observation/2-dom-mutations/prose.md b/apps/tutorial/public/docs/x-10-observation/2-dom-mutations/prose.md
new file mode 100644
index 000000000..b4c9360a2
--- /dev/null
+++ b/apps/tutorial/public/docs/x-10-observation/2-dom-mutations/prose.md
@@ -0,0 +1,93 @@
+# Observing DOM Mutations
+
+The [MutationObserver API](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) provides a way to watch for changes being made to the DOM tree. This is useful when you need to respond to dynamic changes in the DOM, such as elements being added or removed, attributes changing, or text content being modified.
+
+## MutationObserver API
+
+The MutationObserver API allows you to observe changes to the DOM tree and react to those changes:
+
+```js
+// Create a new MutationObserver
+const observer = new MutationObserver(mutations => {
+ for (const mutation of mutations) {
+ // Handle the mutation
+ console.log('Mutation type:', mutation.type);
+
+ if (mutation.type === 'childList') {
+ console.log('Nodes added:', mutation.addedNodes);
+ console.log('Nodes removed:', mutation.removedNodes);
+ } else if (mutation.type === 'attributes') {
+ console.log('Changed attribute:', mutation.attributeName);
+ } else if (mutation.type === 'characterData') {
+ console.log('Text content changed');
+ }
+ }
+});
+
+// Start observing an element with configuration options
+observer.observe(element, {
+ childList: true, // Observe direct children
+ attributes: true, // Observe attributes
+ characterData: true, // Observe text content
+ subtree: true // Observe all descendants
+});
+
+// Stop observing
+observer.disconnect();
+```
+
+## Using MutationObserver in Ember Components
+
+In Ember components, you can use the MutationObserver in a modifier:
+
+```js
+import Component from '@glimmer/component';
+import { modifier } from 'ember-modifier';
+
+export default class MutationAwareComponent extends Component {
+ mutationObserver = modifier((element) => {
+ const observer = new MutationObserver(mutations => {
+ for (const mutation of mutations) {
+ this.handleMutation(mutation);
+ }
+ });
+
+ observer.observe(element, {
+ childList: true,
+ attributes: true,
+ characterData: true,
+ subtree: true
+ });
+
+ return () => {
+ observer.disconnect();
+ };
+ });
+
+ handleMutation(mutation) {
+ // Handle the mutation
+ console.log('Mutation detected:', mutation.type);
+ }
+
+
+
+ Content that might change
+
+
+}
+```
+
+
+ Complete the DomMutations component by implementing the mutation observer modifier:
+
+
Create a modifier that uses MutationObserver to track DOM changes
+
Update the component state to display information about mutations
+
Make sure to clean up the observer when the component is destroyed
+
+
+
+[Documentation for MutationObserver][mdn-mutation-observer]
+[Documentation for Ember Modifiers][ember-modifiers]
+
+[mdn-mutation-observer]: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
+[ember-modifiers]: https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/#toc_custom-modifiers
diff --git a/apps/tutorial/public/docs/x-10-observation/3-window-resize/answer.gjs b/apps/tutorial/public/docs/x-10-observation/3-window-resize/answer.gjs
new file mode 100644
index 000000000..cd9955680
--- /dev/null
+++ b/apps/tutorial/public/docs/x-10-observation/3-window-resize/answer.gjs
@@ -0,0 +1,243 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { modifier } from 'ember-modifier';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+
+class WindowResize extends Component {
+ @tracked windowWidth = window.innerWidth;
+ @tracked windowHeight = window.innerHeight;
+ @tracked isObserving = false;
+ @tracked debounceTime = 250; // ms
+ @tracked lastResizeTime = null;
+ @tracked resizeCount = 0;
+
+ // Implement a debounce function to limit how often the resize handler is called
+ debounce(func, wait) {
+ let timeout;
+ return function() {
+ const context = this;
+ const args = arguments;
+ clearTimeout(timeout);
+ timeout = setTimeout(() => {
+ func.apply(context, args);
+ }, wait);
+ };
+ }
+
+ // Implement a window resize modifier that updates width and height
+ windowResizeObserver = modifier(() => {
+ // Create a resize handler function
+ const handleResize = this.debounce(() => {
+ this.windowWidth = window.innerWidth;
+ this.windowHeight = window.innerHeight;
+ this.lastResizeTime = new Date().toLocaleTimeString();
+ this.resizeCount++;
+ }, this.debounceTime);
+
+ // Add event listener for window resize
+ window.addEventListener('resize', handleResize);
+
+ // Initialize the values
+ this.windowWidth = window.innerWidth;
+ this.windowHeight = window.innerHeight;
+ this.lastResizeTime = new Date().toLocaleTimeString();
+
+ // Return a cleanup function
+ return () => {
+ // Clean up the event listener
+ window.removeEventListener('resize', handleResize);
+ };
+ });
+
+ @action
+ toggleObserving() {
+ this.isObserving = !this.isObserving;
+ if (this.isObserving) {
+ this.resizeCount = 0;
+ }
+ }
+
+ @action
+ updateDebounceTime(event) {
+ this.debounceTime = parseInt(event.target.value, 10);
+ }
+
+
+
+
Window Resize Observer
+
+
+
+
+
+
+
+ {{this.debounceTime}}ms
+
+
+
+
+
+
Window Width
+
{{this.windowWidth}}px
+
+
+
+
Window Height
+
{{this.windowHeight}}px
+
+
+
+ {{#if this.isObserving}}
+
+
Last resize: {{this.lastResizeTime}}
+
Resize events handled: {{this.resizeCount}}
+
Try resizing your window quickly and observe how the debounce time affects the update frequency.
+
+ {{/if}}
+
+
+
{{if this.isObserving "Resize your browser window to see the dimensions update in real-time!" "Click the 'Start Observing' button to begin tracking window resize events."}}
+
{{if this.isObserving "Adjust the debounce slider to control how responsive the updates are. A higher value means less frequent updates during rapid resizing." ""}}
+
+
+
+
How It Works
+
This component uses a window resize event listener with debouncing:
+
+
diff --git a/apps/tutorial/public/docs/x-10-observation/3-window-resize/prompt.gjs b/apps/tutorial/public/docs/x-10-observation/3-window-resize/prompt.gjs
new file mode 100644
index 000000000..2ee0c15c3
--- /dev/null
+++ b/apps/tutorial/public/docs/x-10-observation/3-window-resize/prompt.gjs
@@ -0,0 +1,151 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { modifier } from 'ember-modifier';
+import { on } from '@ember/modifier';
+import { action } from '@ember/object';
+
+class WindowResize extends Component {
+ @tracked windowWidth = window.innerWidth;
+ @tracked windowHeight = window.innerHeight;
+ @tracked isObserving = false;
+ @tracked debounceTime = 250; // ms
+
+ // TODO: Implement a debounce function to limit how often the resize handler is called
+ debounce(func, wait) {
+ // Implement debouncing logic here
+ }
+
+ // TODO: Implement a window resize modifier that updates width and height
+ windowResizeObserver = modifier(() => {
+ // Create a resize handler function
+
+ // Add event listener for window resize
+
+ // Return a cleanup function
+ return () => {
+ // Clean up the event listener
+ };
+ });
+
+ @action
+ toggleObserving() {
+ this.isObserving = !this.isObserving;
+ }
+
+ @action
+ updateDebounceTime(event) {
+ this.debounceTime = parseInt(event.target.value, 10);
+ }
+
+
+
+
Window Resize Observer
+
+
+
+
+
+
+
+ {{this.debounceTime}}ms
+
+
+
+
+
+
Window Width
+
{{this.windowWidth}}px
+
+
+
+
Window Height
+
{{this.windowHeight}}px
+
+
+
+
+
{{if this.isObserving "Resize your browser window to see the dimensions update in real-time!" "Click the 'Start Observing' button to begin tracking window resize events."}}
+
{{if this.isObserving "Adjust the debounce slider to control how responsive the updates are. A higher value means less frequent updates during rapid resizing." ""}}
+
+
+
+
+
+}
+
+
+
Observing Window Resize
+
+
diff --git a/apps/tutorial/public/docs/x-10-observation/3-window-resize/prose.md b/apps/tutorial/public/docs/x-10-observation/3-window-resize/prose.md
new file mode 100644
index 000000000..d0cb190d9
--- /dev/null
+++ b/apps/tutorial/public/docs/x-10-observation/3-window-resize/prose.md
@@ -0,0 +1,96 @@
+# Observing Window Resize
+
+Modern web applications often need to respond to changes in the window size to create responsive layouts and adapt the user interface. The browser provides events and APIs to detect and respond to window resize events.
+
+## Window Resize Event
+
+The simplest way to observe window resize is by using the `resize` event on the `window` object:
+
+```js
+// Add an event listener for window resize
+window.addEventListener('resize', () => {
+ // Handle the resize event
+ console.log('Window resized!');
+ console.log('New dimensions:', window.innerWidth, 'x', window.innerHeight);
+});
+
+// Remove the event listener when no longer needed
+window.removeEventListener('resize', handleResize);
+```
+
+## Debouncing Resize Events
+
+Since resize events can fire rapidly during a resize operation, it's often a good practice to debounce the handler to avoid performance issues:
+
+```js
+function debounce(func, wait) {
+ let timeout;
+ return function() {
+ const context = this;
+ const args = arguments;
+ clearTimeout(timeout);
+ timeout = setTimeout(() => {
+ func.apply(context, args);
+ }, wait);
+ };
+}
+
+const handleResize = debounce(() => {
+ console.log('Window resized!');
+ console.log('New dimensions:', window.innerWidth, 'x', window.innerHeight);
+}, 250); // Wait 250ms after the last resize event
+
+window.addEventListener('resize', handleResize);
+```
+
+## Using Window Resize in Ember Components
+
+In Ember components, you can use the `resize` event in a modifier:
+
+```js
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { modifier } from 'ember-modifier';
+
+export default class ResizeAwareComponent extends Component {
+ @tracked windowWidth = window.innerWidth;
+ @tracked windowHeight = window.innerHeight;
+
+ windowResize = modifier(() => {
+ const handleResize = () => {
+ this.windowWidth = window.innerWidth;
+ this.windowHeight = window.innerHeight;
+ };
+
+ window.addEventListener('resize', handleResize);
+
+ return () => {
+ window.removeEventListener('resize', handleResize);
+ };
+ });
+
+
+
+ Window size: {{this.windowWidth}} x {{this.windowHeight}}
+
+
+}
+```
+
+
+ Complete the WindowResize component by implementing the window resize observer:
+
+
Create a modifier that uses the window resize event to track window dimensions
+
Implement debouncing to avoid performance issues during rapid resize events
+
Update the component state with the current window dimensions
+
Make sure to clean up the event listener when the component is destroyed
+ {{! TODO: Add a preview that updates as inputs change }}
+
+
+
+
+
Submit Action
+
+
+
+
+ {{! TODO: Display submission result }}
+
+
+
+
+
+
+}
+
+
+
Standalone Inputs in Ember
+
+
diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/1-standalone-inputs/prose.md b/apps/tutorial/public/docs/x-8-bound-form-controls/1-standalone-inputs/prose.md
new file mode 100644
index 000000000..d99f518d9
--- /dev/null
+++ b/apps/tutorial/public/docs/x-8-bound-form-controls/1-standalone-inputs/prose.md
@@ -0,0 +1,82 @@
+# Standalone Form Inputs in Ember
+
+Standalone form inputs are form controls that aren't part of a traditional `