diff --git a/apps/tutorial/public/docs/2-reactivity/8-nested-reactivity/answer.gjs b/apps/tutorial/public/docs/2-reactivity/8-nested-reactivity/answer.gjs new file mode 100644 index 000000000..cfc439af6 --- /dev/null +++ b/apps/tutorial/public/docs/2-reactivity/8-nested-reactivity/answer.gjs @@ -0,0 +1,286 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +// Approach 1: Using tracked classes for nested objects +class Profile { + @tracked name; + @tracked age; + + constructor(name, age) { + this.name = name; + this.age = age; + } +} + +class Address { + @tracked street; + @tracked city; + @tracked zipCode; + + constructor(street, city, zipCode) { + this.street = street; + this.city = city; + this.zipCode = zipCode; + } +} + +// A class to represent a user profile +class User { + @tracked profile; + @tracked address; + @tracked hobbies; + + constructor() { + this.profile = new Profile('John Doe', 30); + this.address = new Address('123 Main St', 'Embertown', '12345'); + this.hobbies = ['reading', 'hiking', 'coding']; + } + + @action + updateName(newName) { + // With tracked classes, we can directly update the property + this.profile.name = newName; + } + + @action + updateAddress(street, city, zipCode) { + // With tracked classes, we can directly update the properties + this.address.street = street; + this.address.city = city; + this.address.zipCode = zipCode; + } + + @action + addHobby(hobby) { + // For arrays, we need to create a new array to trigger reactivity + this.hobbies = [...this.hobbies, hobby]; + } +} + +class NestedReactivity extends Component { + @tracked user = new User(); + @tracked newName = ''; + @tracked newStreet = ''; + @tracked newCity = ''; + @tracked newZipCode = ''; + @tracked newHobby = ''; + + @action + handleNameChange(event) { + this.newName = event.target.value; + } + + @action + handleStreetChange(event) { + this.newStreet = event.target.value; + } + + @action + handleCityChange(event) { + this.newCity = event.target.value; + } + + @action + handleZipCodeChange(event) { + this.newZipCode = event.target.value; + } + + @action + handleHobbyChange(event) { + this.newHobby = event.target.value; + } + + @action + updateUserName() { + if (this.newName) { + this.user.updateName(this.newName); + this.newName = ''; + } + } + + @action + updateUserAddress() { + if (this.newStreet && this.newCity && this.newZipCode) { + this.user.updateAddress(this.newStreet, this.newCity, this.newZipCode); + this.newStreet = ''; + this.newCity = ''; + this.newZipCode = ''; + } + } + + @action + addUserHobby() { + if (this.newHobby) { + this.user.addHobby(this.newHobby); + this.newHobby = ''; + } + } + + +} + + diff --git a/apps/tutorial/public/docs/2-reactivity/8-nested-reactivity/prompt.gjs b/apps/tutorial/public/docs/2-reactivity/8-nested-reactivity/prompt.gjs new file mode 100644 index 000000000..55d8a852e --- /dev/null +++ b/apps/tutorial/public/docs/2-reactivity/8-nested-reactivity/prompt.gjs @@ -0,0 +1,207 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +// A class to represent a user profile +class User { + @tracked profile = { + name: 'John Doe', + age: 30 + }; + + @tracked address = { + street: '123 Main St', + city: 'Embertown', + zipCode: '12345' + }; + + @tracked hobbies = ['reading', 'hiking', 'coding']; + + @action + updateName(newName) { + // TODO: This doesn't trigger reactivity! Fix it. + this.profile.name = newName; + } + + @action + updateAddress(street, city, zipCode) { + // TODO: Implement this method to update the address while maintaining reactivity + } + + @action + addHobby(hobby) { + // TODO: Implement this method to add a hobby while maintaining reactivity + } +} + +class NestedReactivity extends Component { + @tracked user = new User(); + @tracked newName = ''; + @tracked newStreet = ''; + @tracked newCity = ''; + @tracked newZipCode = ''; + @tracked newHobby = ''; + + @action + handleNameChange(event) { + this.newName = event.target.value; + } + + @action + handleStreetChange(event) { + this.newStreet = event.target.value; + } + + @action + handleCityChange(event) { + this.newCity = event.target.value; + } + + @action + handleZipCodeChange(event) { + this.newZipCode = event.target.value; + } + + @action + handleHobbyChange(event) { + this.newHobby = event.target.value; + } + + @action + updateUserName() { + if (this.newName) { + this.user.updateName(this.newName); + this.newName = ''; + } + } + + @action + updateUserAddress() { + if (this.newStreet && this.newCity && this.newZipCode) { + this.user.updateAddress(this.newStreet, this.newCity, this.newZipCode); + this.newStreet = ''; + this.newCity = ''; + this.newZipCode = ''; + } + } + + @action + addUserHobby() { + if (this.newHobby) { + this.user.addHobby(this.newHobby); + this.newHobby = ''; + } + } + + +} + + diff --git a/apps/tutorial/public/docs/2-reactivity/8-nested-reactivity/prose.md b/apps/tutorial/public/docs/2-reactivity/8-nested-reactivity/prose.md new file mode 100644 index 000000000..788d9e0b3 --- /dev/null +++ b/apps/tutorial/public/docs/2-reactivity/8-nested-reactivity/prose.md @@ -0,0 +1,98 @@ +# Nested Reactivity + +Ember's reactivity system allows for tracking changes to properties and automatically updating the UI when those properties change. When working with nested data structures, understanding how reactivity works becomes even more important. + +## Tracked Properties and Nested Objects + +The `@tracked` decorator marks a property as reactive, but it only tracks changes to the property itself, not to nested properties within objects or arrays. + +For example: + +```js +class Person { + @tracked profile = { name: 'John', age: 30 }; + + updateName(newName) { + // This won't trigger reactivity! + this.profile.name = newName; + } + + updateProfile(newProfile) { + // This will trigger reactivity + this.profile = newProfile; + } +} +``` + +## Solutions for Nested Reactivity + +There are several approaches to handle nested reactivity: + +1. **Replace the entire object**: + ```js + updateName(newName) { + this.profile = { ...this.profile, name: newName }; + } + ``` + +2. **Use tracked objects for nested properties**: + ```js + class Profile { + @tracked name; + @tracked age; + + constructor(name, age) { + this.name = name; + this.age = age; + } + } + + class Person { + @tracked profile; + + constructor() { + this.profile = new Profile('John', 30); + } + + updateName(newName) { + // This will trigger reactivity! + this.profile.name = newName; + } + } + ``` + +3. **Use tracked built-ins**: + The `tracked-built-ins` package provides tracked versions of JavaScript's built-in data structures: + ```js + import { TrackedObject, TrackedArray } from 'tracked-built-ins'; + + class Person { + profile = new TrackedObject({ name: 'John', age: 30 }); + hobbies = new TrackedArray(['reading', 'hiking']); + + updateName(newName) { + // This will trigger reactivity! + this.profile.name = newName; + } + + addHobby(hobby) { + // This will trigger reactivity! + this.hobbies.push(hobby); + } + } + ``` + +

+ Complete the NestedReactivity component by implementing proper nested reactivity: +

+

+ +[Documentation for tracked properties][tracked-properties] +[Documentation for tracked built-ins][tracked-built-ins] + +[tracked-properties]: https://api.emberjs.com/ember/release/modules/@glimmer%2Ftracking +[tracked-built-ins]: https://github.com/tracked-tools/tracked-built-ins diff --git a/apps/tutorial/public/docs/4-logic/5-conditional-attributes/answer.gjs b/apps/tutorial/public/docs/4-logic/5-conditional-attributes/answer.gjs new file mode 100644 index 000000000..51eaecb9f --- /dev/null +++ b/apps/tutorial/public/docs/4-logic/5-conditional-attributes/answer.gjs @@ -0,0 +1,150 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class ConditionalAttributes extends Component { + @tracked isLoading = false; + @tracked buttonState = 'default'; // 'default', 'success', 'danger' + @tracked hasNotification = true; + + @action + toggleLoading() { + this.isLoading = !this.isLoading; + + if (this.isLoading) { + // Simulate an async operation + setTimeout(() => { + this.isLoading = false; + this.buttonState = 'success'; + }, 2000); + } else { + this.buttonState = 'default'; + } + } + + @action + toggleNotification() { + this.hasNotification = !this.hasNotification; + } + + @action + resetState() { + this.buttonState = 'default'; + } + + @action + setDangerState() { + this.buttonState = 'danger'; + } + + +} + + diff --git a/apps/tutorial/public/docs/4-logic/5-conditional-attributes/prompt.gjs b/apps/tutorial/public/docs/4-logic/5-conditional-attributes/prompt.gjs new file mode 100644 index 000000000..c204f002b --- /dev/null +++ b/apps/tutorial/public/docs/4-logic/5-conditional-attributes/prompt.gjs @@ -0,0 +1,151 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class ConditionalAttributes extends Component { + @tracked isLoading = false; + @tracked buttonState = 'default'; // 'default', 'success', 'danger' + @tracked hasNotification = true; + + @action + toggleLoading() { + this.isLoading = !this.isLoading; + + if (this.isLoading) { + // Simulate an async operation + setTimeout(() => { + this.isLoading = false; + this.buttonState = 'success'; + }, 2000); + } else { + this.buttonState = 'default'; + } + } + + @action + toggleNotification() { + this.hasNotification = !this.hasNotification; + } + + @action + resetState() { + this.buttonState = 'default'; + } + + @action + setDangerState() { + this.buttonState = 'danger'; + } + + +} + + 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: +

+

+ +[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>/gi, '') + .replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, ''); + } +}; + +class SafeHtml extends Component { + get sanitizedContent() { + // First sanitize the HTML content + const sanitized = DOMPurify.sanitize(this.args.content); + + // Then mark it as safe for Ember to render + return htmlSafe(sanitized); + } + + +} + + 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>/gi, '') + .replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, ''); + } +}; + +class SafeHtml extends Component { + get sanitizedContent() { + // TODO: Sanitize the HTML content and mark it as safe + return this.args.content; + } + + +} + + 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(); + } + } + + +} + +class ModalDemo extends Component { + @tracked isModalOpen = false; + + @action + openModal() { + this.isModalOpen = true; + } + + @action + closeModal() { + this.isModalOpen = false; + } + + +} + + diff --git a/apps/tutorial/public/docs/6-component-patterns/10-modal/prompt.gjs b/apps/tutorial/public/docs/6-component-patterns/10-modal/prompt.gjs new file mode 100644 index 000000000..532b45dbf --- /dev/null +++ b/apps/tutorial/public/docs/6-component-patterns/10-modal/prompt.gjs @@ -0,0 +1,86 @@ +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(); + } + } + + +} + +class ModalDemo extends Component { + @tracked isModalOpen = false; + + @action + openModal() { + this.isModalOpen = true; + } + + @action + closeModal() { + this.isModalOpen = false; + } + + +} + + 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: +

+

+ +[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 { + +} + 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 { + +} + 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 { + +} + +// Alert Component +class AlertComponent extends Component { + +} + +// Profile Component +class ProfileComponent extends Component { + +} + +// Stats Component +class StatsComponent extends Component { + +} + +class DynamicRenderingDemo extends Component { + // Available components for dynamic rendering + availableComponents = [ + { id: 'card', name: 'Card' }, + { id: 'alert', name: 'Alert' }, + { id: 'profile', name: 'Profile' }, + { id: 'stats', name: 'Stats' } + ]; + + // Currently selected component + @tracked selectedComponent = 'card'; + + // Component-specific properties + @tracked cardTitle = 'Card Title'; + @tracked cardContent = 'This is a sample card component with customizable content.'; + @tracked cardShowButton = true; + @tracked cardButtonText = 'Click Me'; + + @tracked alertType = 'info'; + @tracked alertTitle = 'Information'; + @tracked alertMessage = 'This is an informational alert message.'; + + @tracked profileName = 'Jane Doe'; + @tracked profileRole = 'Software Engineer'; + @tracked profileBio = 'Passionate about building great user experiences with Ember.js.'; + @tracked profileAvatarColor = '#3498db'; + + @tracked statsItems = [ + { label: 'Users', value: '1,234' }, + { label: 'Revenue', value: '$5,678' }, + { label: 'Conversion', value: '12%' }, + { label: 'Growth', value: '+24%' } + ]; + + // Alert type options + alertTypes = [ + { id: 'info', name: 'Info' }, + { id: 'success', name: 'Success' }, + { id: 'warning', name: 'Warning' }, + { id: 'error', name: 'Error' } + ]; + + // Get the component name for dynamic rendering + get componentToRender() { + return `${this.selectedComponent}-component`; + } + + // Get the initials for the profile avatar + get profileInitials() { + return this.profileName + .split(' ') + .map(name => name.charAt(0)) + .join('') + .toUpperCase(); + } + + // Actions + @action + selectComponent(componentId) { + this.selectedComponent = componentId; + } + + @action + handleCardAction() { + alert('Card button clicked!'); + } + + @action + changeAlertType(event) { + this.alertType = event.target.value; + } + + @action + toggleCardButton() { + this.cardShowButton = !this.cardShowButton; + } + + +} + + 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 + + +} + + 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: +

+

+ +[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(); + } + + +} + +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; + } + + +} + + diff --git a/apps/tutorial/public/docs/6-component-patterns/portalling/prompt.gjs b/apps/tutorial/public/docs/6-component-patterns/portalling/prompt.gjs new file mode 100644 index 000000000..3b7c41e2d --- /dev/null +++ b/apps/tutorial/public/docs/6-component-patterns/portalling/prompt.gjs @@ -0,0 +1,73 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class PortallingDemo extends Component { + // TODO: Add tracked properties for portal state + + // TODO: Add getter for portal target element + + // TODO: Add actions for showing and hiding portalled content + + +} + + 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: +

+

+ +[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; + } + + +} + + 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; + } + + +} + + 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: +

+

+ +[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/prompt.gjs b/apps/tutorial/public/docs/7-css-animations/2-transforms/prompt.gjs new file mode 100644 index 000000000..4c944460f --- /dev/null +++ b/apps/tutorial/public/docs/7-css-animations/2-transforms/prompt.gjs @@ -0,0 +1,88 @@ +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: +

+

+ +[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; + } + + +} + + diff --git a/apps/tutorial/public/docs/7-css-animations/3-keyframes/prompt.gjs b/apps/tutorial/public/docs/7-css-animations/3-keyframes/prompt.gjs new file mode 100644 index 000000000..69c2cc0a4 --- /dev/null +++ b/apps/tutorial/public/docs/7-css-animations/3-keyframes/prompt.gjs @@ -0,0 +1,101 @@ +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; + } + + +} + + diff --git a/apps/tutorial/public/docs/7-css-animations/3-keyframes/prose.md b/apps/tutorial/public/docs/7-css-animations/3-keyframes/prose.md new file mode 100644 index 000000000..d6b2da177 --- /dev/null +++ b/apps/tutorial/public/docs/7-css-animations/3-keyframes/prose.md @@ -0,0 +1,54 @@ +# CSS Keyframe Animations + +CSS keyframe animations provide more control over animations than transitions. They allow you to define multiple steps or keyframes in an animation sequence. + +## Basic @keyframes Syntax + +The basic syntax for CSS keyframe animations includes: + +```css +@keyframes animation-name { + 0% { + /* CSS properties at start */ + } + 50% { + /* CSS properties at middle */ + } + 100% { + /* CSS properties at end */ + } +} + +.element { + animation-name: animation-name; + animation-duration: time-in-seconds; + animation-timing-function: easing-function; + animation-delay: time-in-seconds; + animation-iteration-count: number | infinite; + animation-direction: normal | reverse | alternate | alternate-reverse; + animation-fill-mode: none | forwards | backwards | both; +} +``` + +Or using the shorthand: + +```css +.element { + animation: animation-name duration timing-function delay iteration-count direction fill-mode; +} +``` + +

+ Complete the KeyframeDemo component by implementing the missing CSS keyframe animations: +

+

+ +[Documentation for CSS Animations][mdn-animations] +[Documentation for Ember Modifiers][docs-modifiers] + +[mdn-animations]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Animations/Using_CSS_animations +[docs-modifiers]: https://api.emberjs.com/ember/release/modules/@ember%2Fmodifier diff --git a/apps/tutorial/public/docs/7-css-animations/4-matrix-transforms/answer.gjs b/apps/tutorial/public/docs/7-css-animations/4-matrix-transforms/answer.gjs new file mode 100644 index 000000000..af302da6a --- /dev/null +++ b/apps/tutorial/public/docs/7-css-animations/4-matrix-transforms/answer.gjs @@ -0,0 +1,146 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class MatrixTransforms 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/4-matrix-transforms/prompt.gjs b/apps/tutorial/public/docs/7-css-animations/4-matrix-transforms/prompt.gjs new file mode 100644 index 000000000..935ba9900 --- /dev/null +++ b/apps/tutorial/public/docs/7-css-animations/4-matrix-transforms/prompt.gjs @@ -0,0 +1,120 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class MatrixTransforms 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/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: +

+

+ +[Documentation for CSS transform matrix][mdn-matrix] +[Documentation for CSS transform functions][mdn-transform-functions] +[Matrix Calculator Tool][matrix-calculator] + +[mdn-matrix]: https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix +[mdn-transform-functions]: https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function +[matrix-calculator]: https://www.useragentman.com/matrix/ diff --git a/apps/tutorial/public/docs/7-css-animations/5-glitch-effects/answer.gjs b/apps/tutorial/public/docs/7-css-animations/5-glitch-effects/answer.gjs new file mode 100644 index 000000000..7792f4d3c --- /dev/null +++ b/apps/tutorial/public/docs/7-css-animations/5-glitch-effects/answer.gjs @@ -0,0 +1,308 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class GlitchEffects extends Component { + @tracked isTextGlitching = false; + @tracked isImageGlitching = false; + @tracked isFlickering = false; + + @action + toggleTextGlitch() { + this.isTextGlitching = !this.isTextGlitching; + } + + @action + toggleImageGlitch() { + this.isImageGlitching = !this.isImageGlitching; + } + + @action + toggleFlicker() { + this.isFlickering = !this.isFlickering; + } + + +} + + diff --git a/apps/tutorial/public/docs/7-css-animations/5-glitch-effects/prompt.gjs b/apps/tutorial/public/docs/7-css-animations/5-glitch-effects/prompt.gjs new file mode 100644 index 000000000..bf321c43d --- /dev/null +++ b/apps/tutorial/public/docs/7-css-animations/5-glitch-effects/prompt.gjs @@ -0,0 +1,177 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class GlitchEffects extends Component { + @tracked isTextGlitching = false; + @tracked isImageGlitching = false; + @tracked isFlickering = false; + + @action + toggleTextGlitch() { + this.isTextGlitching = !this.isTextGlitching; + } + + @action + toggleImageGlitch() { + this.isImageGlitching = !this.isImageGlitching; + } + + @action + toggleFlicker() { + this.isFlickering = !this.isFlickering; + } + + +} + + diff --git a/apps/tutorial/public/docs/7-css-animations/5-glitch-effects/prose.md b/apps/tutorial/public/docs/7-css-animations/5-glitch-effects/prose.md new file mode 100644 index 000000000..689e4b79d --- /dev/null +++ b/apps/tutorial/public/docs/7-css-animations/5-glitch-effects/prose.md @@ -0,0 +1,94 @@ +# CSS Glitch Effects + +Glitch effects are popular in modern web design, creating a distorted, digital error aesthetic. These effects can be created using CSS animations and clever combinations of text shadows, clip-path, and filters. + +## Understanding Glitch Animations + +Glitch effects typically involve: + +1. **Layer splitting**: Creating copies of text or images with slight offsets +2. **Color shifting**: Using RGB color splits +3. **Distortion**: Applying transformations or clip-path to create jagged edges +4. **Flickering**: Rapidly changing properties to create a flickering effect + +## Creating Text Glitch Effects + +Text glitch effects often use multiple text-shadow properties with different colors and positions, combined with animations that change these properties rapidly: + +```css +.glitch-text { + position: relative; + animation: glitch 1s infinite; +} + +@keyframes glitch { + 0% { + text-shadow: 2px 0 0 red, -2px 0 0 blue; + transform: translate(0); + } + 25% { + text-shadow: -2px 0 0 red, 2px 0 0 blue; + transform: translate(1px); + } + 50% { + text-shadow: 2px 0 0 red, -2px 0 0 blue; + transform: translate(0); + } + 75% { + text-shadow: -2px 0 0 red, 2px 0 0 blue; + transform: translate(-1px); + } + 100% { + text-shadow: 2px 0 0 red, -2px 0 0 blue; + transform: translate(0); + } +} +``` + +## Creating Image Glitch Effects + +For images, we can use the `clip-path` property to create slices and the `filter` property to add color distortion: + +```css +.glitch-image { + position: relative; + animation: image-glitch 2s infinite; +} + +@keyframes image-glitch { + 0% { + clip-path: inset(10% 0 20% 0); + filter: hue-rotate(0deg); + } + 10% { + clip-path: inset(40% 0 50% 0); + filter: hue-rotate(90deg); + } + 20% { + clip-path: inset(20% 0 30% 0); + filter: hue-rotate(180deg); + } + /* ... more keyframes ... */ + 100% { + clip-path: inset(10% 0 20% 0); + filter: hue-rotate(0deg); + } +} +``` + +

+ Complete the GlitchEffects component by implementing the missing glitch animations: +

+

+ +[Documentation for CSS Animations][mdn-animations] +[Documentation for CSS Filters][mdn-filters] +[Documentation for clip-path][mdn-clip-path] + +[mdn-animations]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Animations/Using_CSS_animations +[mdn-filters]: https://developer.mozilla.org/en-US/docs/Web/CSS/filter +[mdn-clip-path]: https://developer.mozilla.org/en-US/docs/Web/CSS/clip-path diff --git a/apps/tutorial/public/docs/7-form-data/2-on-change/answer.gjs b/apps/tutorial/public/docs/7-form-data/2-on-change/answer.gjs new file mode 100644 index 000000000..f8661b780 --- /dev/null +++ b/apps/tutorial/public/docs/7-form-data/2-on-change/answer.gjs @@ -0,0 +1,237 @@ +import Component from '@glimmer/component'; +import { on } from '@ember/modifier'; +import { cell } from 'ember-resources'; + +class FormChangeEvents extends Component { + // Create cells to store form data for different event types + inputData = cell({}); + changeData = cell({}); + submitData = cell({}); + focusEvents = cell([]); + + // Event handlers for different form events + handleInput = (event) => { + let formData = new FormData(event.currentTarget); + let data = Object.fromEntries(formData.entries()); + + this.inputData.current = data; + }; + + handleChange = (event) => { + let formData = new FormData(event.currentTarget); + let data = Object.fromEntries(formData.entries()); + + this.changeData.current = data; + }; + + handleSubmit = (event) => { + event.preventDefault(); + + let formData = new FormData(event.currentTarget); + let data = Object.fromEntries(formData.entries()); + + this.submitData.current = data; + console.log('Form submitted:', data); + }; + + handleFocusOut = (event) => { + if (event.target.name) { + const fieldName = event.target.name; + const timestamp = new Date().toLocaleTimeString(); + + this.focusEvents.current = [ + { field: fieldName, time: timestamp }, + ...this.focusEvents.current.slice(0, 4) // Keep only the 5 most recent events + ]; + + console.log(`Field '${fieldName}' lost focus at ${timestamp}`); + } + }; + + +} + + diff --git a/apps/tutorial/public/docs/7-form-data/2-on-change/prompt.gjs b/apps/tutorial/public/docs/7-form-data/2-on-change/prompt.gjs new file mode 100644 index 000000000..657fd4d90 --- /dev/null +++ b/apps/tutorial/public/docs/7-form-data/2-on-change/prompt.gjs @@ -0,0 +1,126 @@ +import Component from '@glimmer/component'; +import { on } from '@ember/modifier'; +import { cell } from 'ember-resources'; + +class FormChangeEvents extends Component { + // Create cells to store form data for different event types + inputData = cell({}); + changeData = cell({}); + submitData = cell({}); + + // TODO: Implement event handlers for input, change, submit, and focusout events + + +} + + 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; +}; + + +``` + +

+ Complete the FormChangeEvents component by: +

+

+ +[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; + } + + + + @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" + } +} + + 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 + + +} + + 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: +

+

+ +[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/prompt.gjs b/apps/tutorial/public/docs/7-form-data/4-checkboxes/prompt.gjs new file mode 100644 index 000000000..c538b0725 --- /dev/null +++ b/apps/tutorial/public/docs/7-form-data/4-checkboxes/prompt.gjs @@ -0,0 +1,139 @@ +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; + + 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' } + ]; + + // TODO: Implement the update handler for the single checkbox + + // TODO: Implement the update handler for the checkbox group + + // TODO: Implement the "Select All" functionality + + +} + + 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: +

+

+ +[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); + } + + +} + + 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 + + +} + + 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: +

+

+ +[Documentation for contenteditable attribute][mdn-contenteditable] +[Documentation for execCommand (for formatting)][mdn-execcommand] +[Documentation for FormData][mdn-formdata] + +[mdn-contenteditable]: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/contenteditable +[mdn-execcommand]: https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand +[mdn-formdata]: https://developer.mozilla.org/en-US/docs/Web/API/FormData diff --git a/apps/tutorial/public/docs/7-form-data/file-inputs/answer.gjs b/apps/tutorial/public/docs/7-form-data/file-inputs/answer.gjs new file mode 100644 index 000000000..512f7c4dc --- /dev/null +++ b/apps/tutorial/public/docs/7-form-data/file-inputs/answer.gjs @@ -0,0 +1,395 @@ +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; + + @action + updateSelectedFiles(event) { + // Convert the FileList to an array + this.selectedFiles = Array.from(event.target.files); + } + + @action + updateImagePreview(event) { + const file = event.target.files[0]; + + if (file && file.type.startsWith('image/')) { + this.readImageFile(file); + } else { + this.imagePreview = null; + } + } + + readImageFile(file) { + const reader = new FileReader(); + + reader.onload = (event) => { + this.imagePreview = event.target.result; + }; + + reader.onerror = () => { + console.error('Error reading file'); + this.imagePreview = null; + }; + + reader.readAsDataURL(file); + } + + @action + handleFormSubmit(event) { + event.preventDefault(); + + const formData = new FormData(event.target); + + // FormData automatically includes file inputs + const profilePicture = formData.get('profilePicture'); + const documents = formData.getAll('documents'); + + // Create a formatted string representation of the form data + let formDataString = ''; + + formDataString += `Name: ${formData.get('name')}\n`; + formDataString += `Email: ${formData.get('email')}\n`; + + if (profilePicture && profilePicture.name) { + formDataString += `Profile Picture: ${profilePicture.name} (${this.formatFileSize(profilePicture.size)})\n`; + } else { + formDataString += 'Profile Picture: None\n'; + } + + formDataString += 'Documents:\n'; + if (documents.length > 0) { + documents.forEach((doc, index) => { + formDataString += ` ${index + 1}. ${doc.name} (${this.formatFileSize(doc.size)})\n`; + }); + } else { + formDataString += ' None\n'; + } + + this.formData = formDataString; + console.log('Form submitted:', formData); + } + + formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + getFileIcon(mimeType) { + if (mimeType.startsWith('image/')) { + return '🖼️'; + } else if (mimeType.startsWith('video/')) { + return '🎬'; + } else if (mimeType.startsWith('audio/')) { + return '🎵'; + } else if (mimeType.includes('pdf')) { + return '📄'; + } else if (mimeType.includes('word') || mimeType.includes('document')) { + return '📝'; + } else if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) { + return '📊'; + } else if (mimeType.includes('zip') || mimeType.includes('compressed')) { + return '🗜️'; + } else { + return '📁'; + } + } + + +} + + 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 + + +} + + 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: +

+

+ +[Documentation for HTML file input element][mdn-file-input] +[Documentation for File API][mdn-file-api] +[Documentation for FileReader API][mdn-filereader] +[Documentation for FormData][mdn-formdata] + +[mdn-file-input]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file +[mdn-file-api]: https://developer.mozilla.org/en-US/docs/Web/API/File +[mdn-filereader]: https://developer.mozilla.org/en-US/docs/Web/API/FileReader +[mdn-formdata]: https://developer.mozilla.org/en-US/docs/Web/API/FormData diff --git a/apps/tutorial/public/docs/7-form-data/groups/answer.gjs b/apps/tutorial/public/docs/7-form-data/groups/answer.gjs new file mode 100644 index 000000000..068810ae4 --- /dev/null +++ b/apps/tutorial/public/docs/7-form-data/groups/answer.gjs @@ -0,0 +1,548 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class FormGroupsDemo extends Component { + @tracked personalInfo = { + firstName: '', + lastName: '', + email: '', + phone: '' + }; + + @tracked addressInfo = { + street: '', + city: '', + state: '', + zipCode: '' + }; + + @tracked preferenceInfo = { + contactMethod: 'email', + newsletter: false, + interests: [] + }; + + @tracked formSubmission = null; + + states = [ + { value: '', label: 'Select a state' }, + { value: 'AL', label: 'Alabama' }, + { value: 'AK', label: 'Alaska' }, + { value: 'AZ', label: 'Arizona' }, + { value: 'AR', label: 'Arkansas' }, + { value: 'CA', label: 'California' }, + { value: 'CO', label: 'Colorado' }, + { value: 'CT', label: 'Connecticut' }, + // More states would be added here + { value: 'WY', label: 'Wyoming' } + ]; + + interestOptions = [ + { value: 'technology', label: 'Technology' }, + { value: 'design', label: 'Design' }, + { value: 'business', label: 'Business' }, + { value: 'marketing', label: 'Marketing' }, + { value: 'education', label: 'Education' } + ]; + + @action + updatePersonalInfo(event) { + const { name, value } = event.target; + this.personalInfo = { + ...this.personalInfo, + [name]: value + }; + } + + @action + updateAddressInfo(event) { + const { name, value } = event.target; + this.addressInfo = { + ...this.addressInfo, + [name]: value + }; + } + + @action + updatePreferenceInfo(event) { + const { name, type, value, checked } = event.target; + + let newValue; + + if (type === 'checkbox') { + if (name === 'newsletter') { + newValue = checked; + } else if (name === 'interests') { + const interests = [...this.preferenceInfo.interests]; + + if (checked) { + if (!interests.includes(value)) { + interests.push(value); + } + } else { + const index = interests.indexOf(value); + if (index !== -1) { + interests.splice(index, 1); + } + } + + newValue = interests; + } + } else { + newValue = value; + } + + this.preferenceInfo = { + ...this.preferenceInfo, + [name]: newValue + }; + } + + @action + handleFormSubmit(event) { + event.preventDefault(); + + const formData = new FormData(event.target); + const flatData = Object.fromEntries(formData.entries()); + + // For checkboxes with multiple values (interests), we need to use getAll + const interests = formData.getAll('interests'); + + // Organize the flat data into groups + const organizedData = { + personalInfo: { + firstName: flatData.firstName, + lastName: flatData.lastName, + email: flatData.email, + phone: flatData.phone + }, + addressInfo: { + street: flatData.street, + city: flatData.city, + state: flatData.state, + zipCode: flatData.zipCode + }, + preferenceInfo: { + contactMethod: flatData.contactMethod, + newsletter: flatData.newsletter ? true : false, + interests + } + }; + + this.formSubmission = organizedData; + console.log('Form submitted:', this.formSubmission); + } + + get isInterestSelected() { + return (interest) => this.preferenceInfo.interests.includes(interest); + } + + +} + + 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 + + +} + + 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 +
+ Personal Information +
+ + +
+
+ + +
+
+``` + +## 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: +

+

+ +[Documentation for HTML fieldset element][mdn-fieldset] +[Documentation for FormData][mdn-formdata] +[Documentation for Object.fromEntries()][mdn-object-fromentries] + +[mdn-fieldset]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/fieldset +[mdn-formdata]: https://developer.mozilla.org/en-US/docs/Web/API/FormData +[mdn-object-fromentries]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/fromEntries diff --git a/apps/tutorial/public/docs/7-form-data/radio-inputs/answer.gjs b/apps/tutorial/public/docs/7-form-data/radio-inputs/answer.gjs new file mode 100644 index 000000000..46435036c --- /dev/null +++ b/apps/tutorial/public/docs/7-form-data/radio-inputs/answer.gjs @@ -0,0 +1,229 @@ +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' } + ]; + + @action + updateSelectedColor(event) { + const colorId = event.target.value; + this.selectedColor = this.colors.find(color => color.id === colorId); + } + + @action + handleFormSubmit(event) { + event.preventDefault(); + + const formData = new FormData(event.target); + const data = Object.fromEntries(formData.entries()); + + // Find the color object that matches the selected color + if (data.favoriteColor) { + data.colorObject = this.colors.find(color => color.id === data.favoriteColor); + } + + this.formData = data; + console.log('Form submitted:', this.formData); + } + + +} + + 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 + + +} + + 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: +

+

+ +[Documentation for HTML radio input][mdn-radio] +[Documentation for FormData][mdn-formdata] +[Documentation for radio input accessibility][mdn-radio-accessibility] + +[mdn-radio]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/radio +[mdn-formdata]: https://developer.mozilla.org/en-US/docs/Web/API/FormData +[mdn-radio-accessibility]: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/radio_role diff --git a/apps/tutorial/public/docs/7-form-data/select-multiple/answer.gjs b/apps/tutorial/public/docs/7-form-data/select-multiple/answer.gjs new file mode 100644 index 000000000..6988bfdcf --- /dev/null +++ b/apps/tutorial/public/docs/7-form-data/select-multiple/answer.gjs @@ -0,0 +1,258 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class MultiSelectDemo extends Component { + @tracked selectedFruits = []; + @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' }, + { id: 6, name: 'Mango', value: 'mango' }, + { id: 7, name: 'Kiwi', value: 'kiwi' }, + { id: 8, name: 'Grapes', value: 'grapes' } + ]; + + @action + updateSelectedFruits(event) { + // Convert the HTMLCollection to an array of values + this.selectedFruits = Array.from(event.target.selectedOptions).map(option => option.value); + } + + @action + handleFormSubmit(event) { + event.preventDefault(); + + const formData = new FormData(event.target); + + // Use getAll() to retrieve all selected values + const selectedFruits = formData.getAll('fruits'); + + this.formData = { + name: formData.get('name'), + email: formData.get('email'), + fruits: selectedFruits + }; + + console.log('Form submitted:', this.formData); + } + + get selectedFruitNames() { + return this.selectedFruits.map(value => { + const fruit = this.fruits.find(f => f.value === value); + return fruit ? fruit.name : value; + }); + } + + +} + + diff --git a/apps/tutorial/public/docs/7-form-data/select-multiple/prompt.gjs b/apps/tutorial/public/docs/7-form-data/select-multiple/prompt.gjs new file mode 100644 index 000000000..a048553a0 --- /dev/null +++ b/apps/tutorial/public/docs/7-form-data/select-multiple/prompt.gjs @@ -0,0 +1,137 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class MultiSelectDemo extends Component { + @tracked selectedFruits = []; + @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' }, + { id: 6, name: 'Mango', value: 'mango' }, + { id: 7, name: 'Kiwi', value: 'kiwi' }, + { id: 8, name: 'Grapes', value: 'grapes' } + ]; + + // TODO: Implement the update handler for the multiple select + + // TODO: Implement the form submission handler + + +} + + 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: +

+

+ +[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; + } + + +} + + 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: +

+

+ +[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; + } + + +} + + 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; + } + + +} + + 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}`); + } + + +} +``` + +

+ Complete the ElementDimensions component by implementing the resize observer modifier: +

+

+ +[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 = []; + } + + +} + + 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 = []; + } + + +} + + 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); + } + + +} +``` + +

+ Complete the DomMutations component by implementing the mutation observer modifier: +

+

+ +[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); + } + + +} + + 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); + } + + +} + + 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); + }; + }); + + +} +``` + +

+ Complete the WindowResize component by implementing the window resize observer: +

+

+ +[Documentation for Window resize event][mdn-resize-event] +[Documentation for Ember Modifiers][ember-modifiers] +[Documentation for Debouncing][debouncing] + +[mdn-resize-event]: https://developer.mozilla.org/en-US/docs/Web/API/Window/resize_event +[ember-modifiers]: https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/#toc_custom-modifiers +[debouncing]: https://css-tricks.com/debouncing-throttling-explained-examples/ diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/1-standalone-inputs/answer.gjs b/apps/tutorial/public/docs/x-8-bound-form-controls/1-standalone-inputs/answer.gjs new file mode 100644 index 000000000..cedc24c68 --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/1-standalone-inputs/answer.gjs @@ -0,0 +1,497 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class StandaloneInputsDemo extends Component { + // Basic input values + @tracked name = ''; + @tracked age = ''; + @tracked subscribed = false; + + // Validation input values + @tracked email = ''; + @tracked password = ''; + + // Validation states + @tracked emailValid = true; + @tracked passwordValid = true; + @tracked emailError = ''; + @tracked passwordError = ''; + + // Submission result + @tracked submissionResult = null; + + get isFormValid() { + return ( + this.name && + this.age && + this.emailValid && + this.passwordValid && + this.email && + this.password + ); + } + + get formattedAge() { + const age = parseInt(this.age, 10); + if (isNaN(age)) return ''; + + if (age < 18) { + return 'Youth'; + } else if (age < 30) { + return 'Young Adult'; + } else if (age < 50) { + return 'Adult'; + } else if (age < 70) { + return 'Senior Adult'; + } else { + return 'Elder'; + } + } + + @action + updateName(event) { + this.name = event.target.value; + } + + @action + updateAge(event) { + this.age = event.target.value; + } + + @action + updateSubscription(event) { + this.subscribed = event.target.checked; + } + + @action + validateEmail(event) { + this.email = event.target.value; + + // Simple email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + if (!this.email) { + this.emailValid = false; + this.emailError = 'Email is required'; + } else if (!emailRegex.test(this.email)) { + this.emailValid = false; + this.emailError = 'Please enter a valid email address'; + } else { + this.emailValid = true; + this.emailError = ''; + } + } + + @action + validatePassword(event) { + this.password = event.target.value; + + if (!this.password) { + this.passwordValid = false; + this.passwordError = 'Password is required'; + } else if (this.password.length < 8) { + this.passwordValid = false; + this.passwordError = 'Password must be at least 8 characters'; + } else if (!/[A-Z]/.test(this.password)) { + this.passwordValid = false; + this.passwordError = 'Password must contain at least one uppercase letter'; + } else if (!/[0-9]/.test(this.password)) { + this.passwordValid = false; + this.passwordError = 'Password must contain at least one number'; + } else { + this.passwordValid = true; + this.passwordError = ''; + } + } + + @action + handleSubmit() { + if (this.isFormValid) { + this.submissionResult = { + name: this.name, + age: this.age, + subscribed: this.subscribed, + email: this.email, + passwordLength: this.password.length + }; + + console.log('Form submitted:', this.submissionResult); + } + } + + +} + + diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/1-standalone-inputs/prompt.gjs b/apps/tutorial/public/docs/x-8-bound-form-controls/1-standalone-inputs/prompt.gjs new file mode 100644 index 000000000..77b99bda7 --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/1-standalone-inputs/prompt.gjs @@ -0,0 +1,170 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class StandaloneInputsDemo extends Component { + // TODO: Add tracked properties for input values + + // TODO: Add computed properties for derived values + + // TODO: Add validation methods + + // TODO: Add submit action + + +} + + 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 `
` element but still provide user interaction. These inputs can be useful for creating dynamic interfaces where data is processed immediately rather than on form submission. + +## Working with Standalone Inputs + +When working with standalone inputs in Ember, it's important to understand: + +1. Inputs can exist outside of a `` element and still function properly +2. You can use the same event handlers (`input`, `change`, etc.) as with form-bound inputs +3. Standalone inputs are ideal for immediate feedback and real-time validation +4. You can still use tracked properties to maintain the input state + +## Controlled Standalone Inputs + +A controlled standalone input manages its value through Ember's reactivity system: + +```js +@tracked inputValue = ''; + +updateValue = (event) => { + this.inputValue = event.target.value; +} +``` + +## Immediate Feedback with Standalone Inputs + +One advantage of standalone inputs is the ability to provide immediate feedback: + +```js +@tracked inputValue = ''; +@tracked isValid = true; +@tracked errorMessage = ''; + +validateInput = (event) => { + this.inputValue = event.target.value; + + if (this.inputValue.length < 3) { + this.isValid = false; + this.errorMessage = 'Input must be at least 3 characters'; + } else { + this.isValid = true; + this.errorMessage = ''; + } +} +``` + +## Combining Multiple Standalone Inputs + +You can combine multiple standalone inputs to create complex interfaces: + +```js +@tracked firstName = ''; +@tracked lastName = ''; +@tracked email = ''; + +get fullName() { + return `${this.firstName} ${this.lastName}`.trim(); +} + +get isFormComplete() { + return this.firstName && this.lastName && this.email; +} +``` + +

+ Complete the StandaloneInputsDemo component by: +

    +
  • Implementing controlled standalone inputs for text, number, and checkbox types
  • +
  • Adding real-time validation with immediate feedback
  • +
  • Creating a dynamic preview that updates as the user interacts with the inputs
  • +
  • Implementing a submit action that processes the collected data
  • +
+

+ +[Documentation for HTML input element][mdn-input] +[Documentation for Ember tracked properties][ember-tracked] +[Documentation for Ember actions][ember-actions] + +[mdn-input]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input +[ember-tracked]: https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/ +[ember-actions]: https://guides.emberjs.com/release/components/component-state-and-actions/ diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/2-number/answer.gjs b/apps/tutorial/public/docs/x-8-bound-form-controls/2-number/answer.gjs new file mode 100644 index 000000000..34cf3df58 --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/2-number/answer.gjs @@ -0,0 +1,544 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class NumberInputDemo extends Component { + // Basic number input + @tracked basicValue = 0; + + // Validated number input + @tracked validatedValue = 50; + @tracked validationError = null; + + // Slider and synchronized input + @tracked sliderValue = 50; + + // Calculator + @tracked firstNumber = 10; + @tracked secondNumber = 5; + @tracked operation = 'add'; + @tracked calculationResult = null; + + get isValidatedValueValid() { + return this.validationError === null; + } + + get calculatorOperations() { + return [ + { id: 'add', label: '+', title: 'Add' }, + { id: 'subtract', label: '-', title: 'Subtract' }, + { id: 'multiply', label: '×', title: 'Multiply' }, + { id: 'divide', label: '÷', title: 'Divide' } + ]; + } + + @action + updateBasicValue(event) { + const value = event.target.value; + + // Handle empty input + if (value === '') { + this.basicValue = null; + } else { + this.basicValue = Number(value); + } + } + + @action + updateValidatedValue(event) { + const value = event.target.value; + + // Handle empty input + if (value === '') { + this.validatedValue = null; + this.validationError = 'Value is required'; + return; + } + + const numericValue = Number(value); + + // Validate the input + if (isNaN(numericValue)) { + this.validationError = 'Please enter a valid number'; + } else if (numericValue < 0) { + this.validationError = 'Value cannot be negative'; + } else if (numericValue > 100) { + this.validationError = 'Value cannot exceed 100'; + } else { + this.validatedValue = numericValue; + this.validationError = null; + } + } + + @action + updateSliderValue(event) { + this.sliderValue = Number(event.target.value); + } + + @action + updateSliderInput(event) { + const value = event.target.value; + + // Handle empty input + if (value === '') { + return; + } + + const numericValue = Number(value); + + // Ensure the value is within the slider range + if (!isNaN(numericValue) && numericValue >= 0 && numericValue <= 100) { + this.sliderValue = numericValue; + } + } + + @action + updateFirstNumber(event) { + const value = event.target.value; + + // Handle empty input + if (value === '') { + this.firstNumber = null; + } else { + this.firstNumber = Number(value); + } + } + + @action + updateSecondNumber(event) { + const value = event.target.value; + + // Handle empty input + if (value === '') { + this.secondNumber = null; + } else { + this.secondNumber = Number(value); + } + } + + @action + updateOperation(event) { + this.operation = event.target.value; + } + + @action + calculate() { + // Ensure both numbers are valid + if (this.firstNumber === null || this.secondNumber === null) { + this.calculationResult = 'Please enter valid numbers'; + return; + } + + let result; + + switch (this.operation) { + case 'add': + result = this.firstNumber + this.secondNumber; + break; + case 'subtract': + result = this.firstNumber - this.secondNumber; + break; + case 'multiply': + result = this.firstNumber * this.secondNumber; + break; + case 'divide': + if (this.secondNumber === 0) { + this.calculationResult = 'Cannot divide by zero'; + return; + } + result = this.firstNumber / this.secondNumber; + break; + default: + this.calculationResult = 'Invalid operation'; + return; + } + + // Format the result to avoid excessive decimal places + if (Number.isInteger(result)) { + this.calculationResult = result; + } else { + this.calculationResult = result.toFixed(2); + } + } + + +} + + diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/2-number/prompt.gjs b/apps/tutorial/public/docs/x-8-bound-form-controls/2-number/prompt.gjs new file mode 100644 index 000000000..ff772a5fe --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/2-number/prompt.gjs @@ -0,0 +1,220 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class NumberInputDemo extends Component { + // TODO: Add tracked properties for numeric values + + // TODO: Add validation properties + + // TODO: Add calculator properties + + // TODO: Implement update handlers for inputs + + // TODO: Implement validation methods + + // TODO: Implement calculator methods + + +} + + diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/2-number/prose.md b/apps/tutorial/public/docs/x-8-bound-form-controls/2-number/prose.md new file mode 100644 index 000000000..e4d7e74e6 --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/2-number/prose.md @@ -0,0 +1,99 @@ +# Number Inputs in Ember + +Number inputs are specialized form controls designed to handle numeric values. In Ember, working with number inputs requires understanding how to properly handle type conversion between strings and numbers. + +## Working with Number Inputs + +When working with number inputs in Ember, it's important to understand: + +1. HTML number inputs return string values, even though they appear to handle numbers +2. You need to explicitly convert between strings and numbers +3. Number inputs have special attributes like `min`, `max`, and `step` +4. Validation is important to ensure numeric values are within expected ranges + +## Controlled Number Inputs + +A controlled number input manages its value through Ember's reactivity system: + +```js +@tracked numericValue = 0; + +updateValue = (event) => { + // Convert string to number + this.numericValue = Number(event.target.value); +} +``` + +## Handling Empty Values + +One challenge with number inputs is handling empty values: + +```js +@tracked numericValue = null; + +updateValue = (event) => { + const value = event.target.value; + + // Handle empty input + if (value === '') { + this.numericValue = null; + } else { + this.numericValue = Number(value); + } +} +``` + +## Input Constraints and Validation + +Number inputs support constraints through HTML attributes: + +```html + +``` + +You can also implement custom validation: + +```js +@tracked numericValue = 0; +@tracked error = null; + +validateValue = (event) => { + const value = Number(event.target.value); + + if (isNaN(value)) { + this.error = 'Please enter a valid number'; + } else if (value < 0) { + this.error = 'Value cannot be negative'; + } else if (value > 100) { + this.error = 'Value cannot exceed 100'; + } else { + this.numericValue = value; + this.error = null; + } +} +``` + +

+ Complete the NumberInputDemo component by: +

    +
  • Implementing controlled number inputs with proper type conversion
  • +
  • Adding validation to ensure values are within specified ranges
  • +
  • Creating a numeric slider with synchronized value display
  • +
  • Implementing a calculator that performs operations on numeric inputs
  • +
+

+ +[Documentation for HTML number input element][mdn-number-input] +[Documentation for Number object in JavaScript][mdn-number] +[Documentation for Ember tracked properties][ember-tracked] + +[mdn-number-input]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/number +[mdn-number]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number +[ember-tracked]: https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/ diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/3-select/answer.gjs b/apps/tutorial/public/docs/x-8-bound-form-controls/3-select/answer.gjs new file mode 100644 index 000000000..c8ce2a0ae --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/3-select/answer.gjs @@ -0,0 +1,433 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class SelectInputDemo extends Component { + // Basic select + @tracked basicSelection = 'option2'; + + basicOptions = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + { value: 'option3', label: 'Option 3' }, + { value: 'option4', label: 'Option 4' } + ]; + + // Dynamic select + @tracked colorSelection = '#3498db'; + + colors = [ + { value: '#3498db', label: 'Blue', name: 'Blue' }, + { value: '#2ecc71', label: 'Green', name: 'Green' }, + { value: '#e74c3c', label: 'Red', name: 'Red' }, + { value: '#f39c12', label: 'Orange', name: 'Orange' }, + { value: '#9b59b6', label: 'Purple', name: 'Purple' }, + { value: '#1abc9c', label: 'Turquoise', name: 'Turquoise' }, + { value: '#34495e', label: 'Dark Blue', name: 'Dark Blue' }, + { value: '#e67e22', label: 'Amber', name: 'Amber' } + ]; + + // Option groups + @tracked languageSelection = 'javascript'; + + languageGroups = [ + { + label: 'Frontend', + options: [ + { value: 'html', label: 'HTML' }, + { value: 'css', label: 'CSS' }, + { value: 'javascript', label: 'JavaScript' }, + { value: 'typescript', label: 'TypeScript' } + ] + }, + { + label: 'Backend', + options: [ + { value: 'python', label: 'Python' }, + { value: 'ruby', label: 'Ruby' }, + { value: 'java', label: 'Java' }, + { value: 'csharp', label: 'C#' } + ] + }, + { + label: 'Database', + options: [ + { value: 'sql', label: 'SQL' }, + { value: 'mongodb', label: 'MongoDB' }, + { value: 'graphql', label: 'GraphQL' } + ] + } + ]; + + // Cascading selects + @tracked categorySelection = 'fruits'; + @tracked itemSelection = null; + + categories = [ + { value: 'fruits', label: 'Fruits' }, + { value: 'vegetables', label: 'Vegetables' }, + { value: 'dairy', label: 'Dairy Products' }, + { value: 'grains', label: 'Grains' } + ]; + + itemsByCategory = { + fruits: [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'orange', label: 'Orange' }, + { value: 'strawberry', label: 'Strawberry' } + ], + vegetables: [ + { value: 'carrot', label: 'Carrot' }, + { value: 'broccoli', label: 'Broccoli' }, + { value: 'spinach', label: 'Spinach' }, + { value: 'potato', label: 'Potato' } + ], + dairy: [ + { value: 'milk', label: 'Milk' }, + { value: 'cheese', label: 'Cheese' }, + { value: 'yogurt', label: 'Yogurt' }, + { value: 'butter', label: 'Butter' } + ], + grains: [ + { value: 'rice', label: 'Rice' }, + { value: 'wheat', label: 'Wheat' }, + { value: 'oats', label: 'Oats' }, + { value: 'barley', label: 'Barley' } + ] + }; + + get currentItems() { + return this.itemsByCategory[this.categorySelection] || []; + } + + get selectedColorObject() { + return this.colors.find(color => color.value === this.colorSelection) || this.colors[0]; + } + + get selectedLanguageObject() { + for (const group of this.languageGroups) { + const found = group.options.find(option => option.value === this.languageSelection); + if (found) { + return found; + } + } + return null; + } + + get selectedItemObject() { + if (!this.itemSelection) { + return null; + } + + return this.currentItems.find(item => item.value === this.itemSelection); + } + + constructor() { + super(...arguments); + // Initialize the item selection with the first item of the default category + this.itemSelection = this.currentItems[0]?.value || null; + } + + @action + updateBasicSelection(event) { + this.basicSelection = event.target.value; + } + + @action + updateColorSelection(event) { + this.colorSelection = event.target.value; + } + + @action + updateLanguageSelection(event) { + this.languageSelection = event.target.value; + } + + @action + updateCategorySelection(event) { + this.categorySelection = event.target.value; + + // Reset the item selection to the first item of the new category + this.itemSelection = this.currentItems[0]?.value || null; + } + + @action + updateItemSelection(event) { + this.itemSelection = event.target.value; + } + + +} + + diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/3-select/prompt.gjs b/apps/tutorial/public/docs/x-8-bound-form-controls/3-select/prompt.gjs new file mode 100644 index 000000000..7b0279507 --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/3-select/prompt.gjs @@ -0,0 +1,140 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class SelectInputDemo extends Component { + // TODO: Add tracked properties for selected values + + // TODO: Define options for the basic select + + // TODO: Define options for the dynamic select + + // TODO: Define option groups + + // TODO: Define options for cascading selects + + // TODO: Implement update handlers + + +} + + diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/3-select/prose.md b/apps/tutorial/public/docs/x-8-bound-form-controls/3-select/prose.md new file mode 100644 index 000000000..8f05888e1 --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/3-select/prose.md @@ -0,0 +1,101 @@ +# Select Inputs in Ember + +Select inputs (dropdowns) allow users to choose from a predefined list of options. In Ember, working with select inputs requires understanding how to manage the selected value and handle option changes. + +## Working with Select Inputs + +When working with select inputs in Ember, it's important to understand: + +1. The `value` attribute of a select element represents the currently selected option +2. Options can be dynamically generated from an array of data +3. The `change` event fires when the user selects a different option +4. You can use tracked properties to maintain the selected value + +## Controlled Select Inputs + +A controlled select input manages its value through Ember's reactivity system: + +```js +@tracked selectedValue = 'option1'; + +updateSelection = (event) => { + this.selectedValue = event.target.value; +} +``` + +## Generating Options Dynamically + +You can generate options dynamically from an array of data: + +```js +options = [ + { id: 'option1', label: 'Option 1' }, + { id: 'option2', label: 'Option 2' }, + { id: 'option3', label: 'Option 3' } +]; + +@tracked selectedValue = this.options[0].id; +``` + +```hbs + +``` + +## Option Groups + +Select inputs can also include option groups for better organization: + +```js +optionGroups = [ + { + label: 'Group 1', + options: [ + { id: 'option1', label: 'Option 1' }, + { id: 'option2', label: 'Option 2' } + ] + }, + { + label: 'Group 2', + options: [ + { id: 'option3', label: 'Option 3' }, + { id: 'option4', label: 'Option 4' } + ] + } +]; +``` + +```hbs + +``` + +

+ Complete the SelectInputDemo component by: +

    +
  • Implementing a controlled select input that updates its state when the selection changes
  • +
  • Creating a dynamic select with options generated from an array
  • +
  • Adding option groups for better organization
  • +
  • Implementing a cascading select where the options of one select depend on the selection of another
  • +
+

+ +[Documentation for HTML select element][mdn-select] +[Documentation for HTML optgroup element][mdn-optgroup] +[Documentation for Ember tracked properties][ember-tracked] +[Documentation for Ember actions][ember-actions] + +[mdn-select]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select +[mdn-optgroup]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/optgroup +[ember-tracked]: https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/ +[ember-actions]: https://guides.emberjs.com/release/components/component-state-and-actions/ diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/4-radio/answer.gjs b/apps/tutorial/public/docs/x-8-bound-form-controls/4-radio/answer.gjs new file mode 100644 index 000000000..255771f3b --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/4-radio/answer.gjs @@ -0,0 +1,471 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class RadioInputDemo extends Component { + // Basic radio group + @tracked basicSelection = 'option2'; + + basicOptions = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + { value: 'option3', label: 'Option 3' } + ]; + + // Dynamic radio group + @tracked colorSelection = 'blue'; + + colors = [ + { value: 'blue', label: 'Blue', hex: '#3498db' }, + { value: 'green', label: 'Green', hex: '#2ecc71' }, + { value: 'red', label: 'Red', hex: '#e74c3c' }, + { value: 'purple', label: 'Purple', hex: '#9b59b6' }, + { value: 'orange', label: 'Orange', hex: '#f39c12' } + ]; + + // Custom styled radio group + @tracked sizeSelection = 'medium'; + + sizes = [ + { value: 'small', label: 'Small' }, + { value: 'medium', label: 'Medium' }, + { value: 'large', label: 'Large' }, + { value: 'x-large', label: 'Extra Large' } + ]; + + // Form radio group + @tracked formSelection = 'email'; + @tracked formResult = null; + + contactMethods = [ + { value: 'email', label: 'Email' }, + { value: 'phone', label: 'Phone' }, + { value: 'mail', label: 'Mail' }, + { value: 'none', label: 'Do not contact' } + ]; + + get selectedColorObject() { + return this.colors.find(color => color.value === this.colorSelection) || this.colors[0]; + } + + @action + updateBasicSelection(event) { + this.basicSelection = event.target.value; + } + + @action + updateColorSelection(event) { + this.colorSelection = event.target.value; + } + + @action + updateSizeSelection(event) { + this.sizeSelection = event.target.value; + } + + @action + updateFormSelection(event) { + this.formSelection = event.target.value; + } + + @action + handleSubmit(event) { + event.preventDefault(); + + // Get the selected contact method object + const selectedMethod = this.contactMethods.find(method => method.value === this.formSelection); + + // Create a result object + this.formResult = { + method: selectedMethod.label, + timestamp: new Date().toLocaleString() + }; + } + + +} + + diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/4-radio/prompt.gjs b/apps/tutorial/public/docs/x-8-bound-form-controls/4-radio/prompt.gjs new file mode 100644 index 000000000..3a88db691 --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/4-radio/prompt.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 RadioInputDemo extends Component { + // TODO: Add tracked properties for selected values + + // TODO: Define options for the basic radio group + + // TODO: Define options for the dynamic radio group + + // TODO: Define options for the custom styled radio group + + // TODO: Implement update handlers + + // TODO: Implement form submission handler + + +} + + diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/4-radio/prose.md b/apps/tutorial/public/docs/x-8-bound-form-controls/4-radio/prose.md new file mode 100644 index 000000000..55d18caf4 --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/4-radio/prose.md @@ -0,0 +1,158 @@ +# Radio Inputs in Ember + +Radio inputs allow users to select a single option from a predefined set of choices. In Ember, working with radio inputs requires understanding how to manage the selected value and handle changes. + +## 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 where only one can be selected +2. The `checked` attribute determines which radio input is selected +3. The `value` attribute defines the value associated with each option +4. You can use tracked properties to maintain the selected value + +## Controlled Radio Inputs + +A controlled radio input group manages its value through Ember's reactivity system: + +```js +@tracked selectedValue = 'option1'; + +updateSelection = (event) => { + this.selectedValue = event.target.value; +} +``` + +```hbs +
+ + + +
+``` + +## Generating Radio Inputs Dynamically + +You can generate radio inputs dynamically from an array of data: + +```js +options = [ + { id: 'option1', label: 'Option 1' }, + { id: 'option2', label: 'Option 2' }, + { id: 'option3', label: 'Option 3' } +]; + +@tracked selectedValue = this.options[0].id; +``` + +```hbs +
+ {{#each this.options as |option|}} + + {{/each}} +
+``` + +## Styling Radio Inputs + +Radio inputs can be styled to improve usability and match your application's design: + +```css +.radio-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.radio-option { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Custom radio appearance */ +.custom-radio { + position: relative; + display: inline-block; + width: 1.25rem; + height: 1.25rem; + border: 2px solid #ccc; + border-radius: 50%; +} + +.custom-radio::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 0.75rem; + height: 0.75rem; + border-radius: 50%; + background-color: #3498db; + opacity: 0; + transition: opacity 0.2s; +} + +input[type="radio"]:checked + .custom-radio::after { + opacity: 1; +} + +/* Hide the actual radio input */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +``` + +

+ Complete the RadioInputDemo component by: +

    +
  • Implementing a controlled radio input group that updates its state when a selection is made
  • +
  • Creating a dynamic radio group with options generated from an array
  • +
  • Adding custom styling to improve the appearance of the radio inputs
  • +
  • Implementing a form that collects and processes the selected radio value
  • +
+

+ +[Documentation for HTML radio input element][mdn-radio] +[Documentation for Ember tracked properties][ember-tracked] +[Documentation for Ember actions][ember-actions] + +[mdn-radio]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/radio +[ember-tracked]: https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/ +[ember-actions]: https://guides.emberjs.com/release/components/component-state-and-actions/ diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/5-textarea/answer.gjs b/apps/tutorial/public/docs/x-8-bound-form-controls/5-textarea/answer.gjs new file mode 100644 index 000000000..2bc46dbd6 --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/5-textarea/answer.gjs @@ -0,0 +1,379 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import { htmlSafe } from '@ember/template'; + +class TextareaInputDemo extends Component { + // Basic textarea + @tracked basicContent = 'This is a basic textarea.'; + + // Formatted textarea + @tracked formattedContent = 'This is a formatted textarea.\n\nIt preserves line breaks and formatting.\n\nTry adding more content!'; + + // Auto-resizing textarea + @tracked autoResizeContent = 'This textarea will automatically resize as you add more content.\n\nTry adding more lines to see it in action!'; + + // Limited textarea + @tracked limitedContent = ''; + @tracked maxLength = 200; + + get formattedPreview() { + // Replace line breaks with HTML line breaks for display + // Use htmlSafe to mark the string as safe HTML + return htmlSafe(this.formattedContent.replace(/\n/g, '
')); + } + + get characterCount() { + return this.limitedContent.length; + } + + get remainingCharacters() { + return this.maxLength - this.characterCount; + } + + get counterClass() { + if (this.remainingCharacters <= 10) { + return 'danger'; + } else if (this.remainingCharacters <= 30) { + return 'warning'; + } + return ''; + } + + get borderColor() { + if (this.remainingCharacters <= 10) { + return '#e74c3c'; + } else if (this.remainingCharacters <= 30) { + return '#f39c12'; + } + return '#ccc'; + } + + @action + updateBasicContent(event) { + this.basicContent = event.target.value; + } + + @action + updateFormattedContent(event) { + this.formattedContent = event.target.value; + } + + @action + updateAutoResizeContent(event) { + this.autoResizeContent = event.target.value; + this.adjustHeight(event.target); + } + + @action + updateLimitedContent(event) { + const value = event.target.value; + + // Enforce the character limit + if (value.length <= this.maxLength) { + this.limitedContent = value; + } else { + // Truncate the input if it exceeds the limit + this.limitedContent = value.slice(0, this.maxLength); + event.target.value = this.limitedContent; + } + } + + @action + adjustHeight(element) { + // Reset height to auto to get the correct scrollHeight + element.style.height = 'auto'; + // Set the height to match the content + element.style.height = `${element.scrollHeight}px`; + } + + @action + setupAutoResize(element) { + // Initial height adjustment + this.adjustHeight(element); + } + + +} + + diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/5-textarea/prompt.gjs b/apps/tutorial/public/docs/x-8-bound-form-controls/5-textarea/prompt.gjs new file mode 100644 index 000000000..00c409b16 --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/5-textarea/prompt.gjs @@ -0,0 +1,177 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import { htmlSafe } from '@ember/template'; + +class TextareaInputDemo extends Component { + // TODO: Add tracked properties for textarea content + + // TODO: Add computed properties for formatted content + + // TODO: Implement update handlers + + // TODO: Implement auto-resize functionality + + +} + + diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/5-textarea/prose.md b/apps/tutorial/public/docs/x-8-bound-form-controls/5-textarea/prose.md new file mode 100644 index 000000000..9f47530bb --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/5-textarea/prose.md @@ -0,0 +1,80 @@ +# Textarea Inputs in Ember + +Textarea inputs allow users to enter multi-line text content. In Ember, working with textarea inputs requires understanding how to manage the content and handle changes. + +## Working with Textarea Inputs + +When working with textarea inputs in Ember, it's important to understand: + +1. Textareas are designed for multi-line text input +2. The content is managed through the `value` attribute +3. The `input` and `change` events fire when the user modifies the content +4. You can use tracked properties to maintain the textarea content + +## Controlled Textarea Inputs + +A controlled textarea input manages its value through Ember's reactivity system: + +```js +@tracked textContent = ''; + +updateContent = (event) => { + this.textContent = event.target.value; +} +``` + +```hbs + +``` + +## Handling Line Breaks and Formatting + +Textareas preserve line breaks and whitespace, which can be important for certain types of content: + +```js +@tracked formattedContent = ''; + +get displayContent() { + // Replace line breaks with HTML line breaks for display + return this.formattedContent.replace(/\n/g, '
'); +} +``` + +## Auto-resizing Textareas + +You can create auto-resizing textareas that adjust their height based on content: + +```js +adjustHeight = (element) => { + // Reset height to auto to get the correct scrollHeight + element.style.height = 'auto'; + // Set the height to match the content + element.style.height = `${element.scrollHeight}px`; +} + +updateWithResize = (event) => { + this.textContent = event.target.value; + this.adjustHeight(event.target); +} +``` + +

+ Complete the TextareaInputDemo component by: +

    +
  • Implementing a controlled textarea input that updates its state when the content changes
  • +
  • Creating a live preview that displays the formatted content
  • +
  • Adding an auto-resizing textarea that adjusts its height based on content
  • +
  • Implementing a character counter and maximum length indicator
  • +
+

+ +[Documentation for HTML textarea element][mdn-textarea] +[Documentation for Ember tracked properties][ember-tracked] +[Documentation for Ember actions][ember-actions] + +[mdn-textarea]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea +[ember-tracked]: https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/ +[ember-actions]: https://guides.emberjs.com/release/components/component-state-and-actions/ diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/6-contenteditable/answer.gjs b/apps/tutorial/public/docs/x-8-bound-form-controls/6-contenteditable/answer.gjs new file mode 100644 index 000000000..01bd1dc1f --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/6-contenteditable/answer.gjs @@ -0,0 +1,433 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import { htmlSafe } from '@ember/template'; + +class ContenteditableDemo extends Component { + // Basic contenteditable + @tracked basicContent = 'This is editable content. Click to edit me!'; + + // Rich text editor + @tracked editorContent = '

This is a rich text editor. Try using the formatting buttons above!

'; + + get safeBasicContent() { + return htmlSafe(this.basicContent); + } + + get safeEditorContent() { + return htmlSafe(this.editorContent); + } + + get plainTextContent() { + // Create a temporary div to extract text content + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = this.editorContent; + return tempDiv.textContent || tempDiv.innerText || ''; + } + + get formattedHtmlContent() { + // Format the HTML for display + return this.editorContent + .replace(//g, '>'); + } + + @action + updateBasicContent(event) { + this.basicContent = event.target.innerHTML; + } + + @action + updateEditorContent(event) { + this.editorContent = event.target.innerHTML; + } + + @action + formatBold() { + document.execCommand('bold', false, null); + // Focus back on the editor + this.focusEditor(); + } + + @action + formatItalic() { + document.execCommand('italic', false, null); + this.focusEditor(); + } + + @action + formatUnderline() { + document.execCommand('underline', false, null); + this.focusEditor(); + } + + @action + formatStrikethrough() { + document.execCommand('strikeThrough', false, null); + this.focusEditor(); + } + + @action + formatHeading() { + document.execCommand('formatBlock', false, '

'); + this.focusEditor(); + } + + @action + formatParagraph() { + document.execCommand('formatBlock', false, '

'); + this.focusEditor(); + } + + @action + insertOrderedList() { + document.execCommand('insertOrderedList', false, null); + this.focusEditor(); + } + + @action + insertUnorderedList() { + document.execCommand('insertUnorderedList', false, null); + this.focusEditor(); + } + + @action + focusEditor() { + // Get the editor element and focus it + const editor = document.getElementById('rich-editor'); + if (editor) { + editor.focus(); + } + } + + @action + setupEditor(element) { + // Set initial content + element.innerHTML = this.editorContent; + } + + +} + + diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/6-contenteditable/prompt.gjs b/apps/tutorial/public/docs/x-8-bound-form-controls/6-contenteditable/prompt.gjs new file mode 100644 index 000000000..8cf5524ef --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/6-contenteditable/prompt.gjs @@ -0,0 +1,179 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import { htmlSafe } from '@ember/template'; + +class ContenteditableDemo extends Component { + // TODO: Add tracked properties for contenteditable content + + // TODO: Add computed properties for formatted content + + // TODO: Implement update handlers + + // TODO: Implement formatting actions + + +} + + diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/6-contenteditable/prose.md b/apps/tutorial/public/docs/x-8-bound-form-controls/6-contenteditable/prose.md new file mode 100644 index 000000000..e7bb30c72 --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/6-contenteditable/prose.md @@ -0,0 +1,87 @@ +# Contenteditable Elements in Ember + +Contenteditable elements allow users to edit content directly within HTML elements. Unlike traditional form inputs, contenteditable can be applied to any HTML element, enabling rich text editing and custom editing experiences. + +## Working with Contenteditable Elements + +When working with contenteditable elements in Ember, it's important to understand: + +1. Any HTML element can be made editable with the `contenteditable` attribute +2. Contenteditable elements don't use the `value` property like form inputs +3. Content is accessed through the `innerHTML` or `textContent` properties +4. The `input` event fires when the user modifies the content + +## Controlled Contenteditable Elements + +A controlled contenteditable element manages its content through Ember's reactivity system: + +```js +@tracked content = 'This is editable content.'; + +updateContent = (event) => { + this.content = event.target.innerHTML; +} +``` + +```hbs +

+ {{this.content}} +
+``` + +## Handling HTML Content + +One challenge with contenteditable elements is that they can contain HTML markup: + +```js +@tracked htmlContent = '

This is formatted content.

'; + +updateHtmlContent = (event) => { + this.htmlContent = event.target.innerHTML; +} + +get safeContent() { + return htmlSafe(this.htmlContent); +} +``` + +## Creating Rich Text Editors + +Contenteditable elements can be used to create rich text editors: + +```js +@tracked editorContent = '

Start typing here...

'; + +formatBold = () => { + document.execCommand('bold', false, null); +} + +formatItalic = () => { + document.execCommand('italic', false, null); +} + +updateEditorContent = (event) => { + this.editorContent = event.target.innerHTML; +} +``` + +

+ Complete the ContenteditableDemo component by: +

    +
  • Implementing a controlled contenteditable element that updates its state when the content changes
  • +
  • Creating a simple rich text editor with formatting controls
  • +
  • Adding a live preview that displays the formatted content
  • +
  • Implementing a way to extract and display the plain text version of the content
  • +
+

+ +[Documentation for contenteditable attribute][mdn-contenteditable] +[Documentation for execCommand][mdn-execcommand] +[Documentation for Ember htmlSafe][ember-htmlsafe] + +[mdn-contenteditable]: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/contenteditable +[mdn-execcommand]: https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand +[ember-htmlsafe]: https://api.emberjs.com/ember/release/functions/@ember%2Ftemplate/htmlSafe diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/groups/answer.gjs b/apps/tutorial/public/docs/x-8-bound-form-controls/groups/answer.gjs new file mode 100644 index 000000000..52ab8e22f --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/groups/answer.gjs @@ -0,0 +1,579 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class FormGroupsDemo extends Component { + // Personal information group + @tracked personalInfo = { + firstName: '', + lastName: '', + email: '' + }; + + // Address information group + @tracked addressInfo = { + street: '', + city: '', + state: '', + zipCode: '' + }; + + // Preferences group + @tracked preferencesInfo = { + theme: 'light', + notifications: false, + newsletter: false + }; + + // Form submission result + @tracked formResult = null; + + // US states for the select dropdown + states = [ + { value: '', label: 'Select a state' }, + { value: 'AL', label: 'Alabama' }, + { value: 'AK', label: 'Alaska' }, + { value: 'AZ', label: 'Arizona' }, + { value: 'AR', label: 'Arkansas' }, + { value: 'CA', label: 'California' }, + { value: 'CO', label: 'Colorado' }, + { value: 'CT', label: 'Connecticut' }, + { value: 'DE', label: 'Delaware' }, + { value: 'FL', label: 'Florida' }, + { value: 'GA', label: 'Georgia' }, + { value: 'HI', label: 'Hawaii' }, + { value: 'ID', label: 'Idaho' }, + { value: 'IL', label: 'Illinois' }, + { value: 'IN', label: 'Indiana' }, + { value: 'IA', label: 'Iowa' }, + { value: 'KS', label: 'Kansas' }, + { value: 'KY', label: 'Kentucky' }, + { value: 'LA', label: 'Louisiana' }, + { value: 'ME', label: 'Maine' }, + { value: 'MD', label: 'Maryland' }, + { value: 'MA', label: 'Massachusetts' }, + { value: 'MI', label: 'Michigan' }, + { value: 'MN', label: 'Minnesota' }, + { value: 'MS', label: 'Mississippi' }, + { value: 'MO', label: 'Missouri' }, + { value: 'MT', label: 'Montana' }, + { value: 'NE', label: 'Nebraska' }, + { value: 'NV', label: 'Nevada' }, + { value: 'NH', label: 'New Hampshire' }, + { value: 'NJ', label: 'New Jersey' }, + { value: 'NM', label: 'New Mexico' }, + { value: 'NY', label: 'New York' }, + { value: 'NC', label: 'North Carolina' }, + { value: 'ND', label: 'North Dakota' }, + { value: 'OH', label: 'Ohio' }, + { value: 'OK', label: 'Oklahoma' }, + { value: 'OR', label: 'Oregon' }, + { value: 'PA', label: 'Pennsylvania' }, + { value: 'RI', label: 'Rhode Island' }, + { value: 'SC', label: 'South Carolina' }, + { value: 'SD', label: 'South Dakota' }, + { value: 'TN', label: 'Tennessee' }, + { value: 'TX', label: 'Texas' }, + { value: 'UT', label: 'Utah' }, + { value: 'VT', label: 'Vermont' }, + { value: 'VA', label: 'Virginia' }, + { value: 'WA', label: 'Washington' }, + { value: 'WV', label: 'West Virginia' }, + { value: 'WI', label: 'Wisconsin' }, + { value: 'WY', label: 'Wyoming' } + ]; + + // Theme options for the select dropdown + themes = [ + { value: 'light', label: 'Light' }, + { value: 'dark', label: 'Dark' }, + { value: 'system', label: 'System Default' } + ]; + + @action + updatePersonalInfo(event) { + const { name, value } = event.target; + + this.personalInfo = { + ...this.personalInfo, + [name]: value + }; + } + + @action + updateAddressInfo(event) { + const { name, value } = event.target; + + this.addressInfo = { + ...this.addressInfo, + [name]: value + }; + } + + @action + updatePreferencesTheme(event) { + const value = event.target.value; + + this.preferencesInfo = { + ...this.preferencesInfo, + theme: value + }; + } + + @action + updatePreferencesCheckbox(event) { + const { name, checked } = event.target; + + this.preferencesInfo = { + ...this.preferencesInfo, + [name]: checked + }; + } + + @action + handleSubmit(event) { + event.preventDefault(); + + // Create a FormData object from the form + const formData = new FormData(event.target); + + // Convert FormData to a plain object + const formValues = Object.fromEntries(formData.entries()); + + // Organize the data into groups + const result = { + personal: { + firstName: formValues.firstName, + lastName: formValues.lastName, + email: formValues.email + }, + address: { + street: formValues.street, + city: formValues.city, + state: formValues.state, + zipCode: formValues.zipCode + }, + preferences: { + theme: formValues.theme, + notifications: formValues.notifications === 'on', + newsletter: formValues.newsletter === 'on' + }, + timestamp: new Date().toLocaleString() + }; + + // Update the result state + this.formResult = result; + } + + +} + + diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/groups/prompt.gjs b/apps/tutorial/public/docs/x-8-bound-form-controls/groups/prompt.gjs new file mode 100644 index 000000000..ba56afa8a --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/groups/prompt.gjs @@ -0,0 +1,132 @@ +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: Add tracked properties for form groups + + // TODO: Add update handlers for each group + + // TODO: Add form submission handler + + +} + + diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/groups/prose.md b/apps/tutorial/public/docs/x-8-bound-form-controls/groups/prose.md new file mode 100644 index 000000000..a70325bcf --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/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 +
+ Personal Information +
+ + +
+
+ + +
+
+``` + +## 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
  • +
+

+ +[Documentation for HTML fieldset element][mdn-fieldset] +[Documentation for FormData][mdn-formdata] +[Documentation for Object.fromEntries()][mdn-object-fromentries] + +[mdn-fieldset]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/fieldset +[mdn-formdata]: https://developer.mozilla.org/en-US/docs/Web/API/FormData +[mdn-object-fromentries]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/fromEntries diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/select-multiple/answer.gjs b/apps/tutorial/public/docs/x-8-bound-form-controls/select-multiple/answer.gjs new file mode 100644 index 000000000..2d91697bf --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/select-multiple/answer.gjs @@ -0,0 +1,502 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class MultiSelectDemo extends Component { + // Basic multiple select + @tracked basicSelectedOptions = ['option2', 'option4']; + + basicOptions = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + { value: 'option3', label: 'Option 3' }, + { value: 'option4', label: 'Option 4' }, + { value: 'option5', label: 'Option 5' } + ]; + + // Styled multiple select + @tracked styledSelectedItems = ['apple', 'banana', 'grape']; + + styledItems = [ + { value: 'apple', label: 'Apple', color: '#e74c3c' }, + { value: 'banana', label: 'Banana', color: '#f1c40f' }, + { value: 'orange', label: 'Orange', color: '#e67e22' }, + { value: 'grape', label: 'Grape', color: '#9b59b6' }, + { value: 'kiwi', label: 'Kiwi', color: '#2ecc71' }, + { value: 'blueberry', label: 'Blueberry', color: '#3498db' }, + { value: 'strawberry', label: 'Strawberry', color: '#e84393' }, + { value: 'pineapple', label: 'Pineapple', color: '#f39c12' } + ]; + + // Form multiple select + @tracked formSelectedSkills = []; + @tracked formResult = null; + + skillOptions = [ + { value: 'html', label: 'HTML', category: 'Frontend' }, + { value: 'css', label: 'CSS', category: 'Frontend' }, + { value: 'javascript', label: 'JavaScript', category: 'Frontend' }, + { value: 'react', label: 'React', category: 'Frontend' }, + { value: 'ember', label: 'Ember.js', category: 'Frontend' }, + { value: 'vue', label: 'Vue.js', category: 'Frontend' }, + { value: 'node', label: 'Node.js', category: 'Backend' }, + { value: 'express', label: 'Express', category: 'Backend' }, + { value: 'python', label: 'Python', category: 'Backend' }, + { value: 'django', label: 'Django', category: 'Backend' }, + { value: 'ruby', label: 'Ruby', category: 'Backend' }, + { value: 'rails', label: 'Ruby on Rails', category: 'Backend' }, + { value: 'sql', label: 'SQL', category: 'Database' }, + { value: 'mongodb', label: 'MongoDB', category: 'Database' }, + { value: 'postgres', label: 'PostgreSQL', category: 'Database' }, + { value: 'redis', label: 'Redis', category: 'Database' } + ]; + + get skillCategories() { + const categories = {}; + + this.skillOptions.forEach(skill => { + if (!categories[skill.category]) { + categories[skill.category] = []; + } + + categories[skill.category].push(skill); + }); + + return Object.entries(categories).map(([name, skills]) => ({ + name, + skills + })); + } + + get selectedBasicOptionLabels() { + return this.basicSelectedOptions.map(value => { + const option = this.basicOptions.find(opt => opt.value === value); + return option ? option.label : value; + }); + } + + get selectedStyledItems() { + return this.styledSelectedItems.map(value => { + return this.styledItems.find(item => item.value === value); + }).filter(Boolean); + } + + get selectedSkillLabels() { + return this.formSelectedSkills.map(value => { + const skill = this.skillOptions.find(opt => opt.value === value); + return skill ? skill.label : value; + }); + } + + @action + updateBasicSelection(event) { + // Convert the HTMLCollection to an array of values + this.basicSelectedOptions = Array.from(event.target.selectedOptions).map(option => option.value); + } + + @action + updateStyledSelection(event) { + // Convert the HTMLCollection to an array of values + this.styledSelectedItems = Array.from(event.target.selectedOptions).map(option => option.value); + } + + @action + updateFormSelection(event) { + // Convert the HTMLCollection to an array of values + this.formSelectedSkills = Array.from(event.target.selectedOptions).map(option => option.value); + } + + @action + handleSubmit(event) { + event.preventDefault(); + + // Create a FormData object from the form + const formData = new FormData(event.target); + + // Use getAll() to retrieve all selected values + const selectedSkills = formData.getAll('skills'); + + // Get the skill labels for display + const skillLabels = selectedSkills.map(value => { + const skill = this.skillOptions.find(opt => opt.value === value); + return skill ? skill.label : value; + }); + + // Create a result object + this.formResult = { + skills: skillLabels, + count: selectedSkills.length, + timestamp: new Date().toLocaleString() + }; + } + + +} + + diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/select-multiple/prompt.gjs b/apps/tutorial/public/docs/x-8-bound-form-controls/select-multiple/prompt.gjs new file mode 100644 index 000000000..30a933843 --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/select-multiple/prompt.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 MultiSelectDemo extends Component { + // TODO: Add tracked properties for selected options + + // TODO: Define options for the multiple select + + // TODO: Implement update handlers + + // TODO: Implement form submission handler + + +} + + diff --git a/apps/tutorial/public/docs/x-8-bound-form-controls/select-multiple/prose.md b/apps/tutorial/public/docs/x-8-bound-form-controls/select-multiple/prose.md new file mode 100644 index 000000000..6b43eb04c --- /dev/null +++ b/apps/tutorial/public/docs/x-8-bound-form-controls/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/x-modifiers/conditional/answer.gjs b/apps/tutorial/public/docs/x-modifiers/conditional/answer.gjs new file mode 100644 index 000000000..ddf7dc0f5 --- /dev/null +++ b/apps/tutorial/public/docs/x-modifiers/conditional/answer.gjs @@ -0,0 +1,440 @@ +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'; + +// Custom conditional modifier that applies a class based on a condition +const conditionalClass = modifier((element, [condition, className]) => { + if (condition) { + element.classList.add(className); + } else { + element.classList.remove(className); + } + + return () => { + element.classList.remove(className); + }; +}); + +// Custom conditional modifier that applies a style based on a condition +const conditionalStyle = modifier((element, [condition, property, value]) => { + if (condition) { + element.style[property] = value; + } else { + element.style[property] = ''; + } + + return () => { + element.style[property] = ''; + }; +}); + +class ConditionalModifiersDemo extends Component { + // Conditional event listeners + @tracked isButtonEnabled = true; + @tracked clickCount = 0; + @tracked lastClickTime = null; + + // Toggle button + @tracked isToggleActive = false; + @tracked toggleClickCount = 0; + + // Hover card + @tracked isHovering = false; + @tracked hoverMessage = 'Hover over the card to see the effect'; + + // Custom conditional modifier + @tracked themeMode = 'light'; + @tracked elementStates = [ + { id: 1, isActive: false }, + { id: 2, isActive: true }, + { id: 3, isActive: false } + ]; + + get isDarkMode() { + return this.themeMode === 'dark'; + } + + @action + toggleButtonState() { + this.isButtonEnabled = !this.isButtonEnabled; + } + + @action + handleButtonClick() { + this.clickCount++; + this.lastClickTime = new Date().toLocaleTimeString(); + } + + @action + toggleActiveState() { + this.isToggleActive = !this.isToggleActive; + this.toggleClickCount++; + } + + @action + handleToggleMouseEnter() { + if (this.isToggleActive) { + console.log('Mouse entered active toggle button'); + } + } + + @action + handleToggleMouseLeave() { + if (this.isToggleActive) { + console.log('Mouse left active toggle button'); + } + } + + @action + handleHoverEnter() { + this.isHovering = true; + this.hoverMessage = 'Card is being hovered!'; + } + + @action + handleHoverLeave() { + this.isHovering = false; + this.hoverMessage = 'Hover over the card to see the effect'; + } + + @action + toggleTheme() { + this.themeMode = this.isDarkMode ? 'light' : 'dark'; + } + + @action + toggleElementState(elementId) { + this.elementStates = this.elementStates.map(element => { + if (element.id === elementId) { + return { ...element, isActive: !element.isActive }; + } + return element; + }); + } + + +} + + diff --git a/apps/tutorial/public/docs/x-modifiers/conditional/prompt.gjs b/apps/tutorial/public/docs/x-modifiers/conditional/prompt.gjs new file mode 100644 index 000000000..32ecf754a --- /dev/null +++ b/apps/tutorial/public/docs/x-modifiers/conditional/prompt.gjs @@ -0,0 +1,144 @@ +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'; + +// TODO: Create a custom conditional modifier + +class ConditionalModifiersDemo extends Component { + // TODO: Add tracked properties for component state + + // TODO: Add action methods for event handling + + +} + + diff --git a/apps/tutorial/public/docs/x-modifiers/conditional/prose.md b/apps/tutorial/public/docs/x-modifiers/conditional/prose.md new file mode 100644 index 000000000..8ea95256c --- /dev/null +++ b/apps/tutorial/public/docs/x-modifiers/conditional/prose.md @@ -0,0 +1,82 @@ +# Conditional Modifiers in Ember + +Conditional modifiers allow you to apply modifiers to elements based on certain conditions. This is useful when you want to conditionally add event listeners, apply styles, or interact with the DOM based on the state of your application. + +## Working with Conditional Modifiers + +When working with conditional modifiers in Ember, it's important to understand: + +1. Modifiers are functions that can interact with DOM elements +2. Conditional modifiers are applied only when a specific condition is met +3. You can use the `if` helper to conditionally apply modifiers +4. Modifiers can be combined with other template features for powerful DOM interactions + +## Basic Conditional Modifiers + +You can use the `if` helper to conditionally apply modifiers: + +```hbs + +``` + +In this example, the `on` modifier is only applied when `isActive` is truthy. + +## Multiple Conditional Modifiers + +You can apply multiple conditional modifiers to the same element: + +```hbs +
+ Hover over me +
+``` + +## Creating Custom Conditional Modifiers + +You can create custom modifiers that include conditional logic: + +```js +// app/modifiers/conditional-class.js +import { modifier } from 'ember-modifier'; + +export default modifier(function conditionalClass(element, [condition, className]) { + if (condition) { + element.classList.add(className); + } else { + element.classList.remove(className); + } + + return () => { + element.classList.remove(className); + }; +}); +``` + +```hbs +
+ This div will have the "active" class when isActive is true +
+``` + +

+ Complete the ConditionalModifiersDemo component by: +

    +
  • Implementing conditional event listeners that are only active under specific conditions
  • +
  • Creating a toggle button that conditionally applies different modifiers based on its state
  • +
  • Building a hover card that uses conditional modifiers to handle mouse interactions
  • +
  • Implementing a custom conditional modifier that applies different styles based on component state
  • +
+

+ +[Documentation for Ember modifiers][ember-modifiers] +[Documentation for if helper][ember-if-helper] +[Documentation for on modifier][ember-on-modifier] + +[ember-modifiers]: https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/ +[ember-if-helper]: https://guides.emberjs.com/release/components/conditional-content/ +[ember-on-modifier]: https://guides.emberjs.com/release/components/component-state-and-actions/#toc_event-handlers diff --git a/apps/tutorial/public/docs/x-multimedia/1-audio-playback/answer.gjs b/apps/tutorial/public/docs/x-multimedia/1-audio-playback/answer.gjs new file mode 100644 index 000000000..f6a534230 --- /dev/null +++ b/apps/tutorial/public/docs/x-multimedia/1-audio-playback/answer.gjs @@ -0,0 +1,180 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class AudioPlayer extends Component { + // Reference to the audio element + audioElement = null; + + // Track playback state + @tracked isPlaying = false; + @tracked currentTime = 0; + @tracked duration = 0; + @tracked volume = 1.0; + + // Set default audio source if not provided + get audioSrc() { + return this.args.src || 'https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3'; + } + + @action + setupAudio(element) { + this.audioElement = element; + + // Set up event listeners + element.addEventListener('timeupdate', () => { + this.currentTime = element.currentTime; + }); + + element.addEventListener('durationchange', () => { + this.duration = element.duration; + }); + + element.addEventListener('play', () => { + this.isPlaying = true; + }); + + element.addEventListener('pause', () => { + this.isPlaying = false; + }); + + element.addEventListener('ended', () => { + this.isPlaying = false; + }); + + // Set initial volume + element.volume = this.volume; + } + + @action + togglePlay() { + if (this.audioElement) { + if (this.isPlaying) { + this.audioElement.pause(); + } else { + this.audioElement.play(); + } + } + } + + @action + setVolume(event) { + const newVolume = event.target.value; + this.volume = newVolume; + + if (this.audioElement) { + this.audioElement.volume = newVolume; + } + } + + @action + setCurrentTime(event) { + const newTime = event.target.value; + + if (this.audioElement) { + this.audioElement.currentTime = newTime; + } + } + + // Format time in MM:SS + formatTime(timeInSeconds) { + if (isNaN(timeInSeconds)) return '00:00'; + + const minutes = Math.floor(timeInSeconds / 60); + const seconds = Math.floor(timeInSeconds % 60); + + return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + } + + +} + + diff --git a/apps/tutorial/public/docs/x-multimedia/1-audio-playback/prompt.gjs b/apps/tutorial/public/docs/x-multimedia/1-audio-playback/prompt.gjs new file mode 100644 index 000000000..4e0956e9b --- /dev/null +++ b/apps/tutorial/public/docs/x-multimedia/1-audio-playback/prompt.gjs @@ -0,0 +1,143 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +class AudioPlayer extends Component { + // Reference to the audio element + audioElement = null; + + // Track playback state + @tracked isPlaying = false; + @tracked currentTime = 0; + @tracked duration = 0; + @tracked volume = 1.0; + + // Set default audio source if not provided + get audioSrc() { + return this.args.src || 'https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3'; + } + + @action + setupAudio(element) { + // TODO: Store the audio element reference + // TODO: Set up event listeners for timeupdate, durationchange, play, pause, and ended + // TODO: Set initial volume + } + + @action + togglePlay() { + // TODO: Implement play/pause functionality + } + + @action + setVolume(event) { + // TODO: Implement volume control + } + + @action + setCurrentTime(event) { + // TODO: Implement seeking functionality + } + + // Format time in MM:SS + formatTime(timeInSeconds) { + if (isNaN(timeInSeconds)) return '00:00'; + + const minutes = Math.floor(timeInSeconds / 60); + const seconds = Math.floor(timeInSeconds % 60); + + return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + } + + +} + + diff --git a/apps/tutorial/public/docs/x-multimedia/1-audio-playback/prose.md b/apps/tutorial/public/docs/x-multimedia/1-audio-playback/prose.md new file mode 100644 index 000000000..5e3d8ac1a --- /dev/null +++ b/apps/tutorial/public/docs/x-multimedia/1-audio-playback/prose.md @@ -0,0 +1,49 @@ +# Building an Audio Player Component + +Audio playback is a common requirement in modern web applications. HTML5 provides the `