diff --git a/sites/website/eleventy.config.js b/sites/website/eleventy.config.js index ad6350885ac..bc754042b6f 100644 --- a/sites/website/eleventy.config.js +++ b/sites/website/eleventy.config.js @@ -57,4 +57,35 @@ export default function (eleventyConfig) { return version; }); + + /** + * Flatten an eleventy-navigation tree depth-first and return the entries + * immediately before and after the current page URL. Used to derive + * prev/next pagination from the sidebar order. + */ + eleventyConfig.addFilter("prevNext", function (navTree, currentUrl) { + const flat = []; + + (function walk(nodes) { + if (!Array.isArray(nodes)) return; + for (const node of nodes) { + if (node?.url) { + flat.push({ url: node.url, title: node.title }); + } + if (node?.children?.length) { + walk(node.children); + } + } + })(navTree); + + const idx = flat.findIndex(entry => entry.url === currentUrl); + if (idx === -1) { + return { prev: null, next: null }; + } + + return { + prev: idx > 0 ? flat[idx - 1] : null, + next: idx < flat.length - 1 ? flat[idx + 1] : null, + }; + }); } diff --git a/sites/website/package.json b/sites/website/package.json index e3dcb0360ef..f748c9f939b 100644 --- a/sites/website/package.json +++ b/sites/website/package.json @@ -7,7 +7,7 @@ "clean": "clean build tmp", "prebuild": "npm run clean && node scripts/generate-docs.cjs 3", "build": "npm run prebuild && npx @11ty/eleventy --output=build --input=src", - "start": "npm run prebuild && npx @11ty/eleventy --output=build --input=src --serve", + "start": "npm run prebuild && npx @11ty/eleventy --output=build --input=src --serve --incremental", "help": "npx @11ty/eleventy --help" }, "browserslist": { diff --git a/sites/website/src/_includes/3x-container.njk b/sites/website/src/_includes/3x-container.njk index 4a1caadd137..0ab4fd790b8 100644 --- a/sites/website/src/_includes/3x-container.njk +++ b/sites/website/src/_includes/3x-container.njk @@ -26,6 +26,8 @@ navigationOptions:
{{ content | safe }} + {% set paginationCollection = collections.3x %} + {% include "pagination-nav.njk" %}
diff --git a/sites/website/src/_includes/pagination-nav.njk b/sites/website/src/_includes/pagination-nav.njk new file mode 100644 index 00000000000..7a1110e6a70 --- /dev/null +++ b/sites/website/src/_includes/pagination-nav.njk @@ -0,0 +1,25 @@ +{# Derives prev/next from the eleventy-navigation tree for the current + collection. Requires `paginationCollection` to be set. #} +{% set siblings = paginationCollection | eleventyNavigation | prevNext(page.url) %} +{% set prevPage = siblings.prev %} +{% set nextPage = siblings.next %} +{% if prevPage or nextPage %} + +{% endif %} diff --git a/sites/website/src/css/main.css b/sites/website/src/css/main.css index 00f57f5101d..45990fe65da 100644 --- a/sites/website/src/css/main.css +++ b/sites/website/src/css/main.css @@ -2696,6 +2696,47 @@ body:not(.navigation-with-keyboard) :not(input):focus { } } +.pagination-nav { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + margin-top: 3rem; +} + +.pagination-nav__link { + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: var(--ifm-global-radius); + display: flex; + flex-direction: column; + line-height: var(--ifm-heading-line-height); + padding: 1rem; + transition: border-color var(--ifm-transition-fast) + var(--ifm-transition-timing-default); +} + +.pagination-nav__link:hover { + border-color: var(--ifm-color-primary); + text-decoration: none; +} + +.pagination-nav__link--next { + grid-column: 2 / 3; + text-align: right; +} + +.pagination-nav__sublabel { + color: var(--ifm-color-content-secondary); + font-size: var(--ifm-h6-font-size); + font-weight: var(--ifm-font-weight-semibold); + margin-bottom: 0.25rem; +} + +.pagination-nav__label { + color: var(--ifm-link-color); + font-weight: var(--ifm-font-weight-bold); + word-break: break-word; +} + @media print { .footer, .menu, diff --git a/sites/website/src/docs/3.x/getting-started/css-templates.md b/sites/website/src/docs/3.x/getting-started/css-templates.md index d45fe1f7cc7..b4663f941ef 100644 --- a/sites/website/src/docs/3.x/getting-started/css-templates.md +++ b/sites/website/src/docs/3.x/getting-started/css-templates.md @@ -16,115 +16,238 @@ keywords: # CSS Templates -The `@microsoft/fast-element` module offers a named export `css` which is a [tag template literal](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals). It can be used to create CSS snippets which will become your web components CSS. These styles are [adoptedStylesheets](https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets) and associated with the `ShadowRoot`, they therefore do not affect styling in the rest of the document. To share styles between a document and web components, we suggest using [CSS properties](https://developer.mozilla.org/en-US/docs/Web/CSS/--*). +Custom elements are typically styled by attaching a ` - +`; ``` -Using the `css` helper, we're able to create `ElementStyles`. We configure this with the element through the `styles` option of the decorator. Internally, `FASTElement` will leverage [Constructable Stylesheet Objects](https://wicg.github.io/construct-stylesheets/) and `ShadowRoot#adoptedStyleSheets` to efficiently re-use CSS across components. This means that even if we have 1k instances of our `name-tag` component, they will all share a single instance of the associated styles, allowing for reduced memory allocation and improved performance. Because the styles are associated with the `ShadowRoot`, they will also be encapsulated. This ensures that your styles don't affect other elements and other element styles don't affect your element. +This example defines a `baseStyles` fragment that sets common styles for the host element. `helloStyles` interpolates `baseStyles` and adds rules of its own, so a component styled with `helloStyles` receives both sets of styles. + +### Mixing Style Sources + +The `styles` property of the `define()` method accepts an array of `ElementStyles` objects, `CSSStyleSheet` instances, and raw CSS strings, letting you combine styles from different sources in a single component. For example: + +```ts +const resetSheet = new CSSStyleSheet(); +resetSheet.replaceSync(`:host { box-sizing: border-box; }`); + +const sharedText = `span { font-family: sans-serif; }`; + +HelloWorld.define({ + name: "hello-world", + template, + styles: [ + resetSheet, // a CSSStyleSheet, adopted as-is + sharedText, // a raw CSS string, parsed into its own stylesheet + css`:host { color: green; }`, // ElementStyles via the css function + ], +}); +``` -## Composing styles +The example above combines three kinds of styles. `resetSheet` is a `CSSStyleSheet` that is adopted directly, `sharedText` is a raw CSS string that FAST parses into its own stylesheet, and the `css` function produces an `ElementStyles` object. Each is applied to the component's shadow root as a separate stylesheet. -One of the nice features of `ElementStyles` is that it can be composed with other styles. Imagine that we had a CSS normalize that we wanted to use in our `name-tag` component. We could compose that into our styles like this: +### Partial CSS -**Example: Composing CSS Registries** +The `css.partial` function creates a reusable fragment of CSS. Unlike a full `ElementStyles` object, which composes as a separate stylesheet, a partial is inlined into the `css` template that interpolates it. This means a partial can appear anywhere in a template, including inside a selector, which lets you share rules, declarations, or selector fragments across components. ```ts -import { normalize } from './normalize'; +import { css } from "@microsoft/fast-element"; -const styles = css` - ${normalize} - :host { - display: inline-block; - contain: content; - color: white; - background: var(--fill-color); - border-radius: var(--border-radius); - min-width: 325px; - text-align: center; - box-shadow: 0 0 calc(var(--depth) * 1px) rgba(0,0,0,.5); +// A partial CSS fragment that matches checked states +const checkedState = css.partial`:is([state--checked], :state(checked))`; + +const checkboxStyles = css` + :host(${checkedState}) { + font-weight: bold; } +`; +``` + +In this example, `checkedState` is a partial CSS fragment that matches elements with a `state--checked` attribute or a `checked` state. The `checkboxStyles` stylesheet includes this fragment in its selector, allowing it to apply styles to the host element when it is in the checked state. + +### CSS Directives + +A CSS directive is the general mechanism for contributing computed styles to a `css` template. The `css.partial` function described above is one built-in example. A directive is any object with a `createCSS()` method, registered with `CSSDirective.define()`. When the directive is interpolated into a `css` template, FAST calls `createCSS()` and incorporates the returned value into the stylesheet. + +```ts +import { css, CSSDirective } from "@microsoft/fast-element"; + +class Elevation implements CSSDirective { + constructor(private level: number) {} - ... + createCSS(): string { + const blur = this.level * 2; + return `box-shadow: 0 ${this.level}px ${blur}px rgba(0, 0, 0, 0.2);`; + } +} + +CSSDirective.define(Elevation); + +const cardStyles = css` + :host { + ${new Elevation(4)} + } `; ``` -Rather than simply concatenating CSS strings, the `css` helper understands that `normalize` is `ElementStyles` and is able to re-use the same Constructable StyleSheet instance as any other component that uses `normalize`. +The `Elevation` directive in the example above returns a `box-shadow` declaration derived from its `level`. Calling `CSSDirective.define()` registers the class so FAST recognizes its instances during composition; without registration, the interpolated object would be stringified. When `cardStyles` is composed, FAST calls `createCSS()` and concatenates the result into the `:host` rule. :::note -You can also pass a CSS `string` or a [CSSStyleSheet](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet) instance directly to the element definition, or even a mixed array of `string`, `CSSStyleSheet`, or `ElementStyles`. +A `cssDirective()` decorator is also available and is equivalent to `CSSDirective.define()`. Annotating the class with `@cssDirective()` registers it the same way. ::: -:::tip -For runtime style changes, keep the `ElementStyles` in your component and call -`$fastController.addStyles()` / `$fastController.removeStyles()` from element -change handlers or lifecycle callbacks. Check out the -[advanced documentation](/docs/advanced/working-with-custom-elements.md) for -details. `css` templates are static and do not support runtime binding -expressions. -::: +The value returned from `createCSS()` follows the same composition rules as any interpolated value. A string is concatenated inline into the surrounding CSS, while an `ElementStyles` or `CSSStyleSheet` is composed as a separate stylesheet. + +A directive runs once, when the template is composed, and its output is fixed in the resulting `ElementStyles`. Because `css` templates are static, a directive cannot respond to component state or produce different styles per instance. Use one to encapsulate reusable style logic that is computed a single time, such as mapping a value to a set of declarations. + +## Changing Styles at Runtime -## Adding external styles +Because `css` templates are static, you do not change a component's styles by re-evaluating its template. Instead, you add, remove, or replace `ElementStyles` through the component's controller, available as `this.$fastController`. -Styles can be added as an array, this can be useful for sharing styles between components and for bringing in styles as a string. +This is most useful when a style depends on a value that can only be computed at runtime. In the following example, a component derives a custom property from a numeric attribute, then builds an `ElementStyles` object and applies it through the controller: -**Example:** ```ts -const sharedStyles = ` - h2 { - font-family: sans-serif; +import { + attr, + css, + type ElementStyles, + FASTElement, + nullableNumberConverter, +} from "@microsoft/fast-element"; + +class MyElement extends FASTElement { + @attr({ converter: nullableNumberConverter }) + ratio?: number | null; + + private ratioStyles?: ElementStyles; + + connectedCallback() { + super.connectedCallback(); + this.updateRatioStyles(); } -`; -NameTag.define({ - name: 'name-tag', - template, - styles: [ - css` + ratioChanged() { + if (this.$fastController.isConnected) { + this.updateRatioStyles(); + } + } + + protected updateRatioStyles() { + this.$fastController.removeStyles(this.ratioStyles); + + this.ratioStyles = css` :host { - color: red; - background: var(--background-color, green); + --fill: ${(this.ratio ?? 0) * 100}%; } - `, - sharedStyles - ] -}) + `; + + this.$fastController.addStyles(this.ratioStyles); + } +} ``` -:::tip -You may notice that we have used [`:host`](https://developer.mozilla.org/en-US/docs/Web/CSS/:host), this is part of standard CSS pseudo classes. Pseudo elements that may be useful for styling your custom web components include [`::slotted`](https://developer.mozilla.org/en-US/docs/Web/CSS/::slotted) and [`::part`](https://developer.mozilla.org/en-US/docs/Web/CSS/::part). -::: +`updateRatioStyles()` removes the previous stylesheet, builds a new one from the current value, and adds it. The stylesheet is held on a private field so it can be removed before the new one is applied. Because the styles are recomputed from a runtime value, a new `ElementStyles` object is created each time rather than reused. + +`addStyles()` and `removeStyles()` act on the component's shadow root, which FAST creates during connection, so the update has to wait for the controller to connect. `ratioChanged()` guards on `this.$fastController.isConnected` and skips the update until then, while `connectedCallback()` runs it once after `super.connectedCallback()` to apply the initial value. + +Applying the value as a stylesheet, rather than writing it to the host's inline `style` attribute, keeps the computed value encapsulated in the component's own styles and leaves the host element's `style` attribute free for consumers. + +To replace the component's primary stylesheet rather than layer on top of it, assign a new `ElementStyles` object to the controller's `mainStyles` property. This removes the previous main styles and applies the new set. + +For styling that can be expressed declaratively, prefer CSS custom properties and state selectors over runtime changes. Use the controller when a style depends on a value the static template cannot express. See the [working with custom elements](../../advanced/working-with-custom-elements/) guide for more on the element controller. + +## Selectors and Custom Properties + +Styling a component's shadow DOM uses standard CSS, but a few selectors are specific to custom elements and shadow trees: + +- [`:host`](https://developer.mozilla.org/en-US/docs/Web/CSS/:host) targets the component's host element. Its functional form, `:host(selector)`, targets the host only when it matches a selector, such as `:host([disabled])` or `:host(${checkedState})`. +- [`::slotted(selector)`](https://developer.mozilla.org/en-US/docs/Web/CSS/::slotted) targets light DOM content projected through a ``. It matches only the top-level slotted nodes, not their descendants. +- [`::part(name)`](https://developer.mozilla.org/en-US/docs/Web/CSS/::part) targets an element the component exposes through the `part` attribute, allowing consumers to style it from outside the shadow boundary. + +Although selectors are scoped to the shadow tree, CSS custom properties inherit through the shadow boundary. This makes them the primary mechanism for customizing a component from the outside. A component reads a custom property with `var()`, supplying a fallback for when it is not set, and a consumer sets the property from the surrounding document. + +```ts +const styles = css` + :host { + color: var(--accent-color, blue); + } + + :host([disabled]) { + opacity: 0.5; + } + + ::slotted(strong) { + color: var(--accent-color, blue); + } +`; +``` + +A consumer customizes the component by setting the custom property outside the shadow boundary: + +```css +my-element { + --accent-color: rebeccapurple; +} +``` + +Because the property inherits into the shadow tree, the component picks up the consumer's value wherever it reads `var(--accent-color)`. Design token systems build on this behavior, exposing a component's customizable values as custom properties. diff --git a/sites/website/src/docs/3.x/getting-started/fast-element.md b/sites/website/src/docs/3.x/getting-started/fast-element.md index 61367153fb8..3c26cb5db1b 100644 --- a/sites/website/src/docs/3.x/getting-started/fast-element.md +++ b/sites/website/src/docs/3.x/getting-started/fast-element.md @@ -6,6 +6,7 @@ eleventyNavigation: key: fast-element3x parent: getting-started3x title: FASTElement + order: 2 navigationOptions: activeKey: fast-element3x keywords: @@ -13,325 +14,279 @@ keywords: - web components --- -# FASTElement +# The `FASTElement` Base Class -The `FASTElement` class can be extended from for your custom component logic. +Custom elements in the browser start by extending the `HTMLElement` class. FAST provides a base class called `FASTElement`, which itself extends `HTMLElement`, and adds reactive property observation, automatic change callbacks, and lifecycle management for building web components. By extending `FASTElement`, you can take advantage of these features while still having access to all the standard Web Component APIs. -## Attribute Bindings +To create a custom FAST element, define a class that extends `FASTElement`. This class will contain the properties, methods, and lifecycle callbacks that define the behavior of your custom element: -Attributes are defined using the `@attr` decorator. - -**Example:** ```ts -import { attr, FASTElement } from '@microsoft/fast-element'; +import { FASTElement } from "@microsoft/fast-element"; export class MyElement extends FASTElement { - @attr - foo: string; + // component logic goes here } ``` -HTML file: -```html - -``` - -An `@attr` can take a configuration with the following options: +## Declaring Attributes with the `@attr` Decorator -| Property | Description | Values | Default | -|-|-|-|-| -| attribute | The attribute name that is reflected in the DOM, this can be specified in cases where a different string is preferred. | `string` | The class property converted to lowercase | -| mode | If the attribute is a boolean and the mode is set to "boolean" this allows `FASTElement` to add/remove the attribute from the element in the same way that [native boolean attributes on elements work](https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML). The "fromView" behavior only updates the property value based on changes in the DOM, but does not reflect property changes back. | `"reflect" \| "boolean" \| "fromView"` | "reflect" | -| converter | This allows the value of the attribute to be converted when moving to and from the HTML template. | [See ValueConverter Interface](#converters) | | +Native custom elements can define attributes by implementing the `observedAttributes` static getter and handling changes in the `attributeChangedCallback`. FAST simplifies this process with the `@attr` decorator, which allows you to declare attributes directly on class properties. -Example with a custom attribute name and boolean mode: ```ts -import { attr, FASTElement } from '@microsoft/fast-element'; +import { attr, FASTElement } from "@microsoft/fast-element"; export class MyElement extends FASTElement { - @attr({ - attribute: "foo-bar", - mode: "boolean" - }) - foo: boolean; + @attr + appearance?: string; } ``` -HTML file: -```html - -``` +In this example, the `@attr` decorator tells `FASTElement` to treat `appearance` as an observed attribute. This means that when you use ``, the `appearance` property on the class will be automatically updated to reflect the value of the attribute. You can also set the property in JavaScript, and it will update the corresponding attribute in the DOM. -:::tip -As a handy feature, attribute names are automatically converted to a lower-case version in HTML, so declaring `fooBar` as an `@attr` in `FASTElement` will in HTML convert to `foobar`. We include the configuration option of `attribute` to allow re-naming, and one of the most common use cases is adding dashes, so you can have `foo-bar` as in the example above. -::: +### Attribute Options -:::important -When the `mode` is set to `boolean`, a built-in `booleanConverter` is automatically used to ensure type correctness so that the manual configuration of the converter is not needed in this common scenario. -::: +The `@attr` decorator accepts an optional configuration object that allows you to customize how the attribute is bound to the property. For example, you can specify a different attribute name, a converter for type coercion, or whether the attribute should reflect back to the DOM. -### Converters +By default, decorated attribute properties use `mode: "reflect"`, which keeps the attribute and property in sync in both directions. -In addition to setting the `mode`, you can also supply a custom `ValueConverter` by setting the `converter` property of the attribute configuration. The converter must implement the following interface: +#### Different Attribute Name + +Attributes in HTML are always case-insensitive, so FAST maps camelCase property names to lowercase attribute names by default. For example, a property named `myAttribute` would correspond to an attribute named `myattribute` in HTML. + +A common convention is to use kebab-case for attribute names, so you can specify a different attribute name using the `attribute` option: ```ts -interface ValueConverter { - toView(value: any): string; - fromView(value: string): any; -} +@attr({ attribute: "my-attribute" }) +myAttribute?: string; ``` -Here's how it works: +#### Boolean Attributes -* When the DOM attribute value changes, the converter's `fromView` method will be called, allowing custom code to coerce the value to the proper type expected by the property. -* When the property value changes, the converter's `fromView` method will also be called, ensuring that the type is correct. After this, the `mode` will be determined. If the mode is set to `reflect` then the converter's `toView` method will be called to allow the type to be formatted before writing to the attribute using `setAttribute`. - -**Example: An Attribute in Reflect Mode with Custom Conversion** +The `mode` property allows for specifying boolean attributes, which are considered `true` if the attribute is present on the element, regardless of its value, and `false` if the attribute is absent: ```ts -import { attr, FASTElement, type ValueConverter } from '@microsoft/fast-element'; - -const numberConverter: ValueConverter = { - toView(value: any): string { - // convert numbers to strings - }, +@attr({ mode: "boolean" }) +enabled?: boolean; +``` - fromView(value: string): any { - // convert strings to numbers - } -}; +#### One-way Binding -export class MyCounter extends FASTElement { - @attr({ converter: numberConverter }) count: number = 0; -} +By default, attributes and properties are kept in sync in both directions. However, if you want to create a one-way binding where changes to the property do not reflect back to the DOM, you can set the `mode` to `"fromView"`: -MyCounter.define({ - name: 'my-counter' -}); +```ts +@attr({ attribute: "initial-value", mode: "fromView" }) +initialValue?: string; ``` -A few commonly used converters are available as well: +#### Value Converters -- [booleanConverter](/docs/3.x/api/fast-element/attr/fast-element.booleanconverter/) -- [nullableBooleanConverter](/docs/3.x/api/fast-element/attr/fast-element.nullablebooleanconverter/) -- [nullableNumberConverter](/docs/3.x/api/fast-element/attr/fast-element.nullablenumberconverter/) +If your property expects a type other than `string`, you can provide a converter object to specify how to convert between the attribute value and the property value. The most common use case for a custom converter is to handle attributes that represent numbers, so FAST provides a built-in `nullableNumberConverter` for this purpose: -## Observables +```ts +import { attr, FASTElement, nullableNumberConverter } from "@microsoft/fast-element"; -While `@attr` is used for primitive properties (string, boolean, and number), the `@observable` decorator is for all other properties. In addition to observing properties, the templating system can also observe arrays. +export class MyElement extends FASTElement { + @attr({ converter: nullableNumberConverter }) + count?: number | null; +} +``` -These decorators are a means of meta-programming the properties on your class, such that they include all the implementation needed to support state tracking, observation, and reactivity. You can access any property within your template, but if it hasn't been decorated with one of these two decorators, its value will not update after the initial render. +This converter will convert the attribute value to a number when reading from the DOM, and convert the value back to a string when writing to the DOM. If the attribute is not present or cannot be converted to a number, the property will be set to `null`. Likewise, if the property is set to `undefined`, `null`, or `NaN`, the attribute will be removed from the DOM. -:::important -Properties with only a getter, that function as a computed property over other observables, should not be decorated with `@attr` or `@observable`. However, they may need to be decorated with `@volatile`, depending on the internal logic. +:::tip +Properties decorated with `@attr({ mode: "boolean" })` automatically utilize the `booleanConverter` converter, which treats the presence of the attribute as `true` and its absence as `false`. ::: -```ts -import { FASTElement, observable } from '@microsoft/fast-element'; +## Property and Attribute Observation -export class MyComponent extends FASTElement { - @observable - someBoolean = false; +When declaring attributes via the platform's native `observedAttributes` API, you must also implement the `attributeChangedCallback` to respond to changes in those attributes. Similarly, if you want to observe changes to properties, you would need to implement getters and setters for those properties. FAST's `@attr` and `@observable` decorators enable observability callbacks without requiring you to implement these patterns directly. - @observable - valueA = 0; +For example, with `@attr`, you can define a callback method that will be called whenever an attribute-bound property changes: - @observable - valueB = 42; +```ts +import { attr, FASTElement } from "@microsoft/fast-element"; + +export class MyElement extends FASTElement { + @attr + name?: string; + + nameChanged(oldValue?: string, newValue?: string) { + console.log(`Name changed from ${oldValue} to ${newValue}`); + } } ``` -A common use case for `@observable` is with slotted elements. +Whenever the `name` property changes, whether through an attribute update in the DOM or by setting the property on an element instance in JavaScript, the `nameChanged` method will be called with the previous and new values. This allows you to react to changes in a declarative way without needing to manually implement attribute observation or property getters/setters. + +### Property Observation with the `@observable` Decorator -**Example: Track changes to elements being added/removed to a slot** +The `@observable` decorator provides similar functionality for properties that are not necessarily tied to attributes. This is useful for internal state management within your component that doesn't need to be reflected in the DOM: ```ts -import { FASTElement, observable } from '@microsoft/fast-element'; +import { observable, FASTElement } from "@microsoft/fast-element"; -class MyComponent extends FASTElement { +export class MyElement extends FASTElement { @observable - public slottedItems: HTMLElement[]; + count: number = 0; - protected itemCount: number; - - public slottedItemsChanged(oldValue: HTMLElement[], newValue: HTMLElement[]): void { - if (this.$fastController.isConnected) { - this.itemCount = newValue.length; - } + countChanged(oldValue: number, newValue: number) { + console.log(`Count changed from ${oldValue} to ${newValue}`); } } ``` -### Manually tracking observables +In this example, the `count` property is decorated with `@observable`, which means that any changes to `count` will trigger the `countChanged` callback, allowing you to respond to changes in the internal state of your component. + +:::tip +FAST's `Observable` API can be used independently of custom elements, so you can create observable objects and properties in any JavaScript class, not just those that extend `FASTElement`. Read the [Reactivity](../../advanced/reactivity/) section for more details on using observables in FAST. +::: -When `@attr` and `@observable` decorated properties are accessed during template rendering, they are tracked, allowing the engine to deeply understand the relationship between your model and view. These decorators serves to meta-program the property for you, injecting code to enable the observation system. However, if you do not like this approach, for `@observable`, you can always implement notification manually. This is especially useful if you need to do some additional logic inside a `getter` and `setter`. Here's what that would look like: +### Manually Tracking Observables -**Example: Manual Observer Implementation** +The `@attr` and `@observable` decorators rewrite the decorated property into a getter/setter pair that calls into FAST's observation system. When a decorated property is accessed during template rendering, the engine tracks the access and establishes the relationship between the model and the view. The same notification behavior can be implemented manually, which is useful when a property requires additional logic in its getter or setter. The following example shows what that looks like: ```ts -import { Observable } from '@microsoft/fast-element'; +import { Observable } from "@microsoft/fast-element"; export class Person { private _name: string; get name() { + // Manually track the property access for reactivity Observable.track(this, 'name'); return this._name; } set name(value: string) { this._name = value; + // Manually notify that the property has changed Observable.notify(this, 'name'); } } ``` -## Emitting Events - -In various scenarios, it may be appropriate for a custom element to publish its own element-specific events. To do this, you can use the `$emit` helper on `FASTElement`. It's a convenience method that creates an instance of `CustomEvent` and uses the `dispatchEvent` API on `FASTElement` with the `bubbles: true` and `composed: true` options. It also ensures that the event is only emitted if the custom element is fully connected to the DOM. +## Lifecycle Callbacks -**Example: Custom Event Dispatch** +Extending `HTMLElement` gives you access to the standard custom element lifecycle callbacks. `FASTElement` supports these callbacks and also provides additional lifecycle management features through its internal controller. When you override lifecycle methods like `connectedCallback` or `disconnectedCallback`, make sure to call `super` to ensure that the base class can perform necessary setup and teardown work. ```ts -const template = html` - -`; +import { FASTElement } from "@microsoft/fast-element"; -export class MyInput extends FASTElement { - @attr - value: string = ''; +export class MyElement extends FASTElement { + connectedCallback() { + super.connectedCallback(); + console.log("Element connected to the DOM"); + } - valueChanged() { - this.$emit('change', this.value); + disconnectedCallback() { + console.log("Element disconnected from the DOM"); + super.disconnectedCallback(); + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { + console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`); + super.attributeChangedCallback(name, oldValue, newValue); } } ``` -:::tip -When emitting custom events, ensure that your event name is always lower-case, so that your Web Components stay compatible with various front-end frameworks that attach events through DOM binding patterns (the DOM is case insensitive). +:::note +Overriding `connectedCallback` can affect the timing of when your template is bound and when behaviors run, so it's generally recommended to call `super.connectedCallback()` at the beginning of your override. Conversely, for `disconnectedCallback` and `attributeChangedCallback`, it's usually best to call the super method at the end of your override to ensure that your custom logic runs while the element is still in a consistent state. ::: -## Defining +## Defining the Element + +### `define()` -`FASTElement` has a `define` method, this is the means by which a custom web component is registered with the browser. +Autonomous custom elements must be registered with the browser using `customElements.define()`. FAST provides a static `define()` method on `FASTElement`-derived classes that wraps this registration process and also allows you to specify additional configuration such as observed attributes, styles, and templates. -**Example:** ```ts -import { FASTElement } from '@microsoft/fast-element'; +import { FASTElement, html, css } from "@microsoft/fast-element"; -export class MyElement extends FASTElement {} +export class MyElement extends FASTElement { + // component logic +} MyElement.define({ - name: 'my-element' + name: "my-element", + template: html`
Hello, World!
`, + styles: css` + div { + color: blue; + } + `, }); ``` -:::important -Defining a web component creates [side effects](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#import_a_module_for_its_side_effects_only). This is important to note as [tree shaking](https://developer.mozilla.org/en-US/docs/Glossary/Tree_shaking) may cause web components to be removed during transpile even if they are imported. Ensure that your build system accounts for this and does not tree shake out your web components. -::: - -This configuration can take various options: +In this example, `MyElement.define()` registers the custom element with the name `my-element`, and provides a template and styles for the element. The template defines the structure of the element's Shadow DOM, while the styles are scoped to that Shadow DOM, ensuring that they do not affect other elements on the page. -| Property | Description | Values | Default | Required | -|-|-|-|-|-| -| name | The [name of the custom element](https://developer.mozilla.org/en-US/docs/Web/API/Web_Components/Using_custom_elements#name). | | | Yes | -| template | The template to render for the custom element. Use the `html` tag template literal to create this template. | | | | -| styles | The styles to associate with the custom element. Use the `css` tag template literal to create this template. | | | | -| shadowOptions | Options controlling the creation of the custom element's shadow DOM. Provide null to render to the associated template to the light DOM instead. Example: `{ delegatesFocus: true }`, see the [ShadowRoot API](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot) for details. | | Defaults to an open shadow root. | | -| elementOptions | [Options](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define#options) controlling how the custom element is defined with the platform. | | | | -| registry | The registry to register this component in by default. | | If not provided, defaults to the global registry. | | +#### Definition Extensions -A typical configuration will at least include `name`, `template`, and `styles`. +`define()` accepts an array of extension callbacks as an optional second argument. Each extension is a function that receives the resolved `FASTElementDefinition` and is called **before** the element is registered with `customElements.define()`. This enables a plugin pattern for hooking into element registration. -**Example:** ```ts -import { attr, css, FASTElement, html } from "@microsoft/fast-element"; - -const template = html`Hello ${x => x.name}!` - -const styles = css` - :host { - border: 1px solid blue; - } -`; +import type { FASTElementExtension } from "@microsoft/fast-element"; -class HelloWorld extends FASTElement { - @attr - name: string; +function myPlugin(): FASTElementExtension { + return definition => { + console.log(`Defining: ${definition.name}`); + }; } -HelloWorld.define({ - name: "hello-world", +MyComponent.define({ + name: "my-component", template, styles, -}); +}, [myPlugin()]); ``` -**Example: Defining with a custom registry** -```ts -export const FooRegistry = Object.freeze({ - prefix: 'foo', - registry: customElements, -}); +Extensions can also be used with the static call style: -HelloWorld.define({ - name: `${FooRegistry.prefix}-tab`, - template, - styles, - registry: FooRegistry.registry, -}); +```ts +FASTElement.define(MyComponent, { name: "my-component" }, [myPlugin()]); ``` -### Define Extensions +For the full set of configuration options accepted by `define()`, see the [`PartialFASTElementDefinition`](/docs/3.x/api/fast-element.partialfastelementdefinition/) API reference. -`define()` accepts an optional second argument — an array of extension callbacks. Each extension is a function that receives the resolved `FASTElementDefinition` and is called **before** the element is registered with `customElements.define()`. This enables a plugin pattern for hooking into element registration. +## Utilities, Helpers, and Additional Features -```ts -import type { FASTElementExtension } from "@microsoft/fast-element"; - -function myPlugin(): FASTElementExtension { - return definition => { - console.log(`Defining: ${definition.name}`); - }; -} +In addition to the features described above, `FASTElement` provides a number of utilities and helpers for working with custom elements, such as methods for adding and removing styles, accessing the element's internal controller, working with the processing queue, and more. -MyComponent.define({ - name: "my-component", - template, - styles, -}, [myPlugin()]); -``` +### Using the Element Controller via `$fastController` -## Lifecycle +Every `FASTElement` instance exposes a `$fastController` property that references the internal `ElementController` driving the element's lifecycle and reactivity. Most components do not need to access it directly, but one member is commonly used inside `*Changed` callbacks: `isConnected`. -All Web Components support a series of lifecycle events that you can tap into to execute custom code at specific points in time. `FASTElement` implements several of these callbacks automatically in order to enable features of its templating engine. However, you can override them to provide your own code. Here's an example of how you would execute custom code when your element is inserted into the DOM. +The `isConnected` property on the controller reports `true` after FAST has connected the element, which happens during `super.connectedCallback()`. It is distinct from the platform's `Node.isConnected`, which reports only whether the element is attached to a document. During the parse-time window when attributes are being assigned to a freshly upgraded element, the platform property is already `true`, but FAST has not yet bound the template or wired up the reactivity system. Reading from `this.elementInternals` or dispatching events during that window can produce inconsistent results. -**Example: Tapping into the Custom Element Lifecycle** +For this reason, `*Changed` callbacks that touch the DOM typically guard against running before the controller has connected: ```ts -import { attr, FASTElement } from '@microsoft/fast-element'; +protected disabledChanged() { + if (!this.$fastController.isConnected) { + return; + } + this.elementInternals.ariaDisabled = `${this.disabled}`; +} +``` -export class NameTag extends FASTElement { - @attr - greeting: string = 'Hello'; +For other uses of the controller, such as dynamically swapping stylesheets at runtime, see [Working with Custom Elements](../../advanced/working-with-custom-elements/). - greetingChanged() { - this.shadowRoot!.innerHTML = this.greeting; - } +### Custom events with `$emit()` - connectedCallback() { - super.connectedCallback(); - console.log('name-tag is now connected to the DOM'); +`FASTElement` provides a helper method called `$emit()` for dispatching custom events from your component. This method simplifies the process of creating and dispatching events by providing a convenient API for specifying event details such as the event name, detail data, and options. + +```ts +import { FASTElement } from "@microsoft/fast-element"; + +export class MyElement extends FASTElement { + handleClick() { + this.$emit("my-event", { some: "data" }, { bubbles: true, composed: true }); } } ``` -The full list of available lifecycle callbacks is: +In this example, the `handleClick` method dispatches a custom event named `my-event` with a detail object containing some data. The event is configured to bubble up through the DOM and to cross the shadow DOM boundary (if applicable) by setting `bubbles: true` and `composed: true` in the options. -| Callback | Description | -| ------------- |-------------| -| constructor | Runs when the element is created or upgraded. `FASTElement` will attach the shadow DOM at this time. | -| connectedCallback | Runs when the element is inserted into the DOM. On first connect, `FASTElement` hydrates the HTML template, connects template bindings, and adds the styles. | -| disconnectedCallback | Runs when the element is removed from the DOM. `FASTElement` will remove template bindings and clean up resources at this time. | -| `Changed(oldVal, newVal)` | Runs any time one of the element's custom attributes changes. `FASTElement` uses this to sync the attribute with its property. When the property updates, a render update is also queued, if there was a template dependency. The naming convention is to add "Changed" to the end of the attribute name, and that is the method that will get called. | -| adoptedCallback | Runs if the element was moved from its current `document` into a new `document` via a call to the `adoptNode(...)` API. | +:::note +`$emit()` only dispatches when the element is connected. Like the pre-connect lifecycle guidance above, this means a `$emit()` call during the parse-time window before the controller has connected is a no-op. If you need to emit an event in response to an early property change, guard on `this.$fastController.isConnected` and defer the dispatch until after the element has connected. +::: diff --git a/sites/website/src/docs/3.x/getting-started/html-directives.md b/sites/website/src/docs/3.x/getting-started/html-directives.md index 73c0bb818ae..a8bd25f0050 100644 --- a/sites/website/src/docs/3.x/getting-started/html-directives.md +++ b/sites/website/src/docs/3.x/getting-started/html-directives.md @@ -20,342 +20,411 @@ keywords: # HTML Directives -FAST provides directives to aide in solving some common scenarios. +When working with a custom element's shadow DOM, you often need references to structural pieces within the template. FAST Element provides directives that reference and manipulate elements in the template and control rendering based on conditions or data. -## ref +## Rendering Directives -Sometimes you need a direct reference to a single DOM node from your template. This might be because you need the rendered dimensions of the node, you want to control the playback of a `video` element, use the drawing context of a `canvas` element, or pass an element to a 3rd party library. Whatever the reason, you can get a reference to the DOM node by using the `ref` directive. +Rendering directives control what renders based on conditions or data. FAST Element provides two: `repeat` and `when`. -**Example: Referencing an Element** +### The `repeat` Directive -```ts -import { attr, FASTElement, html, ref } from '@microsoft/fast-element'; +The `repeat()` directive renders a list of items from an array, using a template to render each one. It accepts an expression which returns an array, an item template, and an optional configuration object. -const template = html` - -`; +For a simple array, the item template renders each entry directly. Within the item template, the source (`x`) is the current item rather than the host component, so typing the template with `html` makes `x` a `string`: -export class MP4Player extends FASTElement { - @attr - src: string; +```ts +import { FASTElement, html, observable, repeat } from '@microsoft/fast-element'; - video: HTMLVideoElement; +const template = html` +
    + ${repeat(x => x.tags, html` +
  • ${x => x}
  • + `)} +
+`; - connectedCallback() { - super.connectedCallback(); - this.video.play(); - } +class TagList extends FASTElement { + @observable + tags: string[] = ["new", "featured", "sale"]; } -MP4Player.define({ - name: "mp4-player", - template +TagList.define({ + name: "tag-list", + template, }); ``` -Place the `ref` directive on the element you want to reference and provide it with a property name to assign the reference to. Once the `connectedCallback` lifecycle event runs, your property will be set to the reference, ready for use. - -:::tip -If you provide a type for your HTML template, TypeScript will type check the property name you provide to ensure that it actually exists on your element. -::: - -## slotted - -Sometimes you may want references to all nodes that are assigned to a particular slot. To accomplish this, use the `slotted` directive. (For more on slots, see [Working with Shadow DOM](/docs/advanced/working-with-custom-elements.md).) +The same pattern works for arrays of objects. Typing the item template with the item's type gives type-checked access to its properties: ```ts -import { FASTElement, html, slotted } from '@microsoft/fast-element'; +import { FASTElement, html, observable, repeat } from '@microsoft/fast-element'; -const template = html` -
- -
+interface Friend { + name: string; + age: number; +} + +const template = html` +
    + ${repeat(x => x.friends, html` +
  • ${x => x.name} is ${x => x.age} years old.
  • + `)} +
`; -export class MyElement extends FASTElement { +class FriendList extends FASTElement { @observable - slottedNodes: Node[]; - - slottedNodesChanged() { - // respond to changes in slotted node - } + friends: Friend[] = [ + { name: "Alice", age: 30 }, + { name: "Bob", age: 25 }, + ]; } -MyElement.define({ - name: 'my-element', - template + +FriendList.define({ + name: "friend-list", + template, }); ``` -Similar to the `children` directive, the `slotted` directive will populate the `slottedNodes` property with nodes assigned to the slot. If `slottedNodes` is decorated with `@observable` then it will be updated dynamically as the assigned nodes change. Like any observable, you can optionally implement a *propertyName*Changed method to be notified when the nodes change. Additionally, you can provide an `options` object to the `slotted` directive to specify a customized configuration for the underlying [assignedNodes() API call](https://developer.mozilla.org/en-US/docs/Web/API/HTMLSlotElement/assignedNodes) or specify a `filter`. - -:::tip -It's best to leverage a change handler for slotted nodes rather than assuming that the nodes will be present in the `connectedCallback`. -::: +Here `html` makes `x` a `Friend`, so references like `x.name` and `x.age` are type-checked. -## children +#### Accessing the Parent Scope -Besides using `ref` to reference a single DOM node, you can use `children` to get references to all child nodes of a particular element. +Within an item template's expressions, the source (`x`) is the current item, so it has no direct reference to the component the `repeat()` is declared in. The execution context (`c`) provides that reference: `c.parent` is the source the directive is bound to, and `c.parentContext` is that source's execution context. For a top-level `repeat()`, `c.parent` is the host component, so an item template can access the host's properties and methods. -**Example: Referencing Child Nodes** +Typing the item template with `html` gives `c.parent` the host's type, so member access like `c.parent.removeFriend(x)` is type-checked: ```ts -import { children, FASTElement, html, repeat } from '@microsoft/fast-element'; +import { FASTElement, html, observable, repeat } from '@microsoft/fast-element'; + +interface Friend { + name: string; +} const template = html` -
    - ${repeat(x => x.friends, html` -
  • ${x => x}
  • +
      + ${repeat(x => x.friends, html` +
    • + ${x => x.name} + +
    • `)}
    `; -export class FriendList extends FASTElement { - @observable - listItems: Node[]; - +class FriendList extends FASTElement { @observable - friends: string[] = []; + friends: Friend[] = [{ name: "Alice" }, { name: "Bob" }]; - connectedCallback() { - super.connectedCallback(); - console.log(this.listItems); + removeFriend(friend: Friend) { + this.friends = this.friends.filter(f => f !== friend); } } FriendList.define({ - name: 'friend-list', - template + name: "friend-list", + template, }); ``` -In the example above, the `listItems` property will be populated with all child nodes of the `ul` element. If `listItems` is decorated with `@observable` then it will be updated dynamically as the child nodes change. Like any observable, you can optionally implement a *propertyName*Changed method to be notified when the nodes change. Additionally, you can provide an `options` object to the `children` directive to specify a customized configuration for the underlying [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver). - -:::important -Like `ref`, the child nodes are not available until the `connectedCallback` lifecycle event. -::: +Here `c.parent` is the `FriendList` instance, so each item's button can call the `removeFriend()` method. When `repeat()` directives are nested, `c.parent` is the item from the enclosing `repeat()` and `c.parentContext` is its execution context, so an item template can reach the scopes that surround it. -:::tip -Using the `children` directive on the `template` element will provide you with references to all Light DOM child nodes of your custom element, regardless of if or where they are slotted. -::: +#### Positioning -You can also provide a `filter` function to control which child nodes are synchronized to your property. As a convenience, we provide an `elements` filter that lets you optionally specify a selector. Taking the above example, if we wanted to ensure that our `listItems` array only included `li` elements (and not any text nodes or other potential child nodes), we could author our template like this: - -**Example: HTML Template with Filtering Child Nodes** +By default, an item template has access to its item but not its position in the list. To use position-dependent values, pass a configuration object as the third argument with the `positioning` property set to `true`. This adds `index` and `length` to the execution context, along with the derived `isFirst`, `isLast`, `isEven`, `isOdd`, and `isInMiddle` properties. ```ts const template = html` -
      - ${repeat(x => x.friends, html` -
    • ${x => x}
    • - `)} -
    +
      + ${repeat( + x => x.friends, + html` +
    1. ${(x, c) => c.index + 1}. ${x => x.name}
    2. + `, + { positioning: true } + )} +
    `; ``` -If using the `subtree` option for `children` then a `selector` is *required* in place of a `filter`. This enables more efficient collection of the desired nodes in the presence of a potential large node quantity throughout the subtree. +Positioning is opt-in because it has a cost: when the list changes, FAST must update the context of the affected items to keep `index` and `length` current. Enable it only when an item template uses position-dependent values. -## when +#### Recycling Views -:::warning -Use sparingly, this will have impacts on performance. If you find yourself using this directive a lot in a single component, consider creating multiple components instead. -::: +When the list changes, `repeat()` reuses existing item views by default rather than creating new ones, which avoids unnecessary allocation. A reused view keeps the DOM from the item it previously displayed, then re-binds it to the new item. Any transient state that a binding does not control, such as scroll position or focus, can carry over. + +To give each item a fresh view instead, set `recycle: false`: + +```ts +const template = html` +
      + ${repeat( + x => x.friends, + html` +
    1. ${x => x.name}
    2. + `, + { recycle: false } + )} +
    +`; +``` + +Disabling recycling avoids stale state at the cost of recreating views, so reserve it for cases where reused DOM causes problems. -The `when` directive enables you to conditionally render blocks of HTML. When you provide an expression to `when` it will render the child template into the DOM when the expression evaluates to `true` and remove the child template when it evaluates to `false` (or if it is never `true`, the rendering will be skipped entirely). +### The `when` Directive -**Example: Conditional Rendering** +The `when()` directive conditionally renders elements. It accepts a predicate function, a template to render when the predicate is true, and an optional template to render when it is false. ```ts import { FASTElement, html, observable, when } from '@microsoft/fast-element'; -const template = html` -

    My App

    +class WhenExample extends FASTElement { + @observable + showContent: boolean = true; +} - ${when(x => !x.ready, html` - Loading... - `)} +const template = html` + `; -export class MyApp extends FASTElement { - @observable - ready: boolean = false; +WhenExample.define({ + name: "when-example", + template, +}); +``` + +## Reference Directives + +Reference directives create references to elements in your template so you can manipulate them from your component's class. FAST Element provides three: `ref`, `slotted`, and `children`. + +### The `ref` Directive + +The `ref()` directive assigns a template element to a property on the component class, so you can reach it through the component instance. + +:::tip +FAST populates the property while binding the view, which the `FASTElement` base class does as part of connecting the element. The property is assigned synchronously during the `super.connectedCallback()` call, and may not be available until after the binding has run. Decorating the property with `@observable` and responding in its change callback is a reliable way to act on the element the moment it becomes available. Without the decorator, the property will still hold the reference once binding has run, but you won't be notified when that happens. +::: + +In the following example, the `ref()` directive maps the `