diff --git a/packages/webawesome/docs/_includes/sidebar.njk b/packages/webawesome/docs/_includes/sidebar.njk index 18ed29c1f..c747b9951 100644 --- a/packages/webawesome/docs/_includes/sidebar.njk +++ b/packages/webawesome/docs/_includes/sidebar.njk @@ -98,6 +98,7 @@
  • Icon
  • Include
  • Input
  • +
  • Intersection Observer
  • Mutation Observer
  • Popover
  • Popup
  • diff --git a/packages/webawesome/docs/docs/components/intersection-observer.md b/packages/webawesome/docs/docs/components/intersection-observer.md new file mode 100644 index 000000000..1252b2bcd --- /dev/null +++ b/packages/webawesome/docs/docs/components/intersection-observer.md @@ -0,0 +1,297 @@ +--- +title: Intersection Observer +description: Tracks immediate child elements and fires events as they move in and out of view. +layout: component +--- + +This component leverages the [IntersectionObserver API](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) to track when its direct children enter or leave a designated root element. The `wa-intersect` event fires whenever elements cross the visibility threshold. + +```html {.example} +
    + +
    +
    +
    + +Scroll to see the element intersect at 100% visibility + + +``` + +:::info +Keep in mind that only direct children of the host element are monitored. Nested elements won't trigger intersection events. +::: + +## Usage Examples + +### Adding Observable Content + +The intersection observer tracks only its direct children. The component uses [`display: contents`](https://developer.mozilla.org/en-US/docs/Web/CSS/display#contents) styling, which makes it seamless to integrate with flex and grid layouts from a parent container. + +```html +
    + +
    Box 1
    +
    Box 2
    +
    Box 3
    +
    +
    +``` + +The component tracks elements as they enter and exit the root element (viewport by default) and emits the `wa-intersect` event on state changes. The event provides `event.detail.entry`, an [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry) object with intersection details. + +You can identify the triggering element through `entry.target`. Check `entry.isIntersecting` to determine if an element is entering or exiting the viewport. + +```javascript +observer.addEventListener('wa-intersect', event => { + const entry = event.detail.entry; + + if (entry.isIntersecting) { + console.log('Element entered viewport:', entry.target); + } else { + console.log('Element left viewport:', entry.target); + } +}); +``` + +### Setting a Custom Root Element + +You can observe intersections within a specific container by assigning the `root` attribute to the [root element's](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/root) ID. Apply [`rootMargin`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/rootMargin) with the `root-margin` attribute to expand or contract the observation area. + +```html +
    + ... +
    +``` + +### Configuring Multiple Thresholds + +Track different visibility percentages by providing multiple [`threshold`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#threshold) values as a space-separated list. + +```html + ... +``` + +### Applying Classes on Intersect + +The `intersect-class` attribute automatically toggles the specified class on direct children when they become visible. This enables pure CSS styling without JavaScript event handlers. + +```html {.example} +
    + +
    Fade In
    +
    Slide In
    +
    Scale & Rotate
    +
    Bounce
    +
    +
    + +Scroll to see elements transition at 50% visibility + + +``` diff --git a/packages/webawesome/docs/docs/resources/changelog.md b/packages/webawesome/docs/docs/resources/changelog.md index d5dd55807..f612b9714 100644 --- a/packages/webawesome/docs/docs/resources/changelog.md +++ b/packages/webawesome/docs/docs/resources/changelog.md @@ -18,6 +18,7 @@ Components with the Experimental badge sh - Removed the `fixed-width` attribute as it's now the default behavior - 🚨 BREAKING: Renamed the `icon-position` attribute to `icon-placement` in `` [discuss:1340] - 🚨 BREAKING: Removed the `size` attribute from `` as it only set the initial size and gets out of sync when buttons are updated (apply a `size` to each button instead) +- Added the `` component - Added the Hindi translation [pr:1307] - Added `--show-duration` and `--hide-duration` to `` [issue:1281] - Fixed incorrectly named exported tooltip parts in `` [pr:1277] diff --git a/packages/webawesome/src/components/intersection-observer/intersection-observer.css b/packages/webawesome/src/components/intersection-observer/intersection-observer.css new file mode 100644 index 000000000..92d692cdd --- /dev/null +++ b/packages/webawesome/src/components/intersection-observer/intersection-observer.css @@ -0,0 +1,3 @@ +:host { + display: contents; +} diff --git a/packages/webawesome/src/components/intersection-observer/intersection-observer.test.ts b/packages/webawesome/src/components/intersection-observer/intersection-observer.test.ts new file mode 100644 index 000000000..0af25892d --- /dev/null +++ b/packages/webawesome/src/components/intersection-observer/intersection-observer.test.ts @@ -0,0 +1,9 @@ +import { expect, fixture, html } from '@open-wc/testing'; + +describe('', () => { + it('should render a component', async () => { + const el = await fixture(html` `); + + expect(el).to.exist; + }); +}); diff --git a/packages/webawesome/src/components/intersection-observer/intersection-observer.ts b/packages/webawesome/src/components/intersection-observer/intersection-observer.ts new file mode 100644 index 000000000..e703be0d0 --- /dev/null +++ b/packages/webawesome/src/components/intersection-observer/intersection-observer.ts @@ -0,0 +1,200 @@ +import { html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { WaIntersectEvent } from '../../events/intersect.js'; +import { clamp } from '../../internal/math.js'; +import { parseSpaceDelimitedTokens } from '../../internal/parse.js'; +import { watch } from '../../internal/watch.js'; +import WebAwesomeElement from '../../internal/webawesome-element.js'; +import styles from './intersection-observer.css'; + +/** + * @summary Tracks immediate child elements and fires events as they move in and out of view. + * @documentation https://webawesome.com/docs/components/intersection-observer + * @status stable + * @since 2.0 + * + * @slot - Elements to track. Only immediate children of the host are monitored. + * + * @event {{ entry: IntersectionObserverEntry }} wa-intersect - Fired when a tracked element begins or ceases intersecting. + */ +@customElement('wa-intersection-observer') +export default class WaIntersectionObserver extends WebAwesomeElement { + static css = styles; + + private intersectionObserver: IntersectionObserver | null = null; + private observedElements = new Map(); + + /** Element ID to define the viewport boundaries for tracked targets. */ + @property() root: string | null = null; + + /** Offset space around the root boundary. Accepts values like CSS margin syntax. */ + @property({ attribute: 'root-margin' }) rootMargin = '0px'; + + /** One or more space-separated values representing visibility percentages that trigger the observer callback. */ + @property() threshold = '0'; + + /** + * CSS class applied to elements during intersection. Automatically removed when elements leave + * the viewport, enabling pure CSS styling based on visibility state. + */ + @property({ attribute: 'intersect-class' }) intersectClass = ''; + + /** If enabled, observation ceases after initial intersection. */ + @property({ type: Boolean, reflect: true }) once = false; + + /** Deactivates the intersection observer functionality. */ + @property({ type: Boolean, reflect: true }) disabled = false; + + connectedCallback() { + super.connectedCallback(); + + if (!this.disabled) { + this.updateComplete.then(() => { + this.startObserver(); + }); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.stopObserver(); + } + + private handleSlotChange() { + if (!this.disabled) { + this.startObserver(); + } + } + + /** Converts threshold property string into numeric array. */ + private parseThreshold(): number[] { + const tokens = parseSpaceDelimitedTokens(this.threshold); + return tokens.map((token: string) => { + const num = parseFloat(token); + return isNaN(num) ? 0 : clamp(num, 0, 1); + }); + } + + /** Locates and returns the root element using the specified ID. */ + private resolveRoot(): Element | null { + if (!this.root) return null; + + try { + const doc = this.getRootNode() as Document | ShadowRoot; + const target = doc.getElementById(this.root); + + if (!target) { + console.warn(`Root element with ID "${this.root}" could not be found.`, this); + } + + return target; + } catch { + console.warn(`Invalid selector for root: "${this.root}"`, this); + return null; + } + } + + /** Initializes or reinitializes the intersection observer instance. */ + private startObserver() { + this.stopObserver(); + + // Skip setup if functionality is disabled + if (this.disabled) return; + + // Convert threshold string to numeric values + const threshold = this.parseThreshold(); + + // Locate the root boundary element + const rootElement = this.resolveRoot(); + + // Set up unified observer for all child elements + this.intersectionObserver = new IntersectionObserver( + entries => { + entries.forEach(entry => { + const wasIntersecting = this.observedElements.get(entry.target) ?? false; + const isIntersecting = entry.isIntersecting; + + // Record current intersection state + this.observedElements.set(entry.target, isIntersecting); + + // Toggle intersection class based on visibility + if (this.intersectClass) { + if (isIntersecting) { + entry.target.classList.add(this.intersectClass); + } else { + entry.target.classList.remove(this.intersectClass); + } + } + + // Emit the intersection event + const changeEvent = new WaIntersectEvent({ entry }); + this.dispatchEvent(changeEvent); + + if (isIntersecting && !wasIntersecting) { + // When once mode is active, cease tracking after first intersection + if (this.once) { + this.intersectionObserver?.unobserve(entry.target); + this.observedElements.delete(entry.target); + } + } + }); + }, + { + root: rootElement, + rootMargin: this.rootMargin, + threshold, + }, + ); + + // Begin tracking all immediate child elements + const slot = this.shadowRoot!.querySelector('slot'); + if (slot !== null) { + const elements = slot.assignedElements({ flatten: true }); + elements.forEach(element => { + this.intersectionObserver?.observe(element); + // Set initial non-intersecting state + this.observedElements.set(element, false); + }); + } + } + + /** Halts the intersection observer and cleans up. */ + private stopObserver() { + // Clear intersection classes from all tracked elements before stopping + if (this.intersectClass) { + this.observedElements.forEach((_, element) => { + element.classList.remove(this.intersectClass); + }); + } + + this.intersectionObserver?.disconnect(); + this.intersectionObserver = null; + this.observedElements.clear(); + } + + @watch('disabled', { waitUntilFirstUpdate: true }) + handleDisabledChange() { + if (this.disabled) { + this.stopObserver(); + } else { + this.startObserver(); + } + } + + @watch('root', { waitUntilFirstUpdate: true }) + @watch('rootMargin', { waitUntilFirstUpdate: true }) + @watch('threshold', { waitUntilFirstUpdate: true }) + handleOptionsChange() { + this.startObserver(); + } + + render() { + return html` `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'wa-intersection-observer': WaIntersectionObserver; + } +} diff --git a/packages/webawesome/src/events/events.ts b/packages/webawesome/src/events/events.ts index e9ef29957..341f6f608 100644 --- a/packages/webawesome/src/events/events.ts +++ b/packages/webawesome/src/events/events.ts @@ -11,6 +11,7 @@ export type { WaExpandEvent } from './expand.js'; export type { WaFinishEvent } from './finish.js'; export type { WaHideEvent } from './hide.js'; export type { WaHoverEvent } from './hover.js'; +export type { WaIntersectEvent } from './intersect.js'; export type { WaInvalidEvent } from './invalid.js'; export type { WaLazyChangeEvent } from './lazy-change.js'; export type { WaLazyLoadEvent } from './lazy-load.js'; diff --git a/packages/webawesome/src/events/intersect.ts b/packages/webawesome/src/events/intersect.ts new file mode 100644 index 000000000..01da80ada --- /dev/null +++ b/packages/webawesome/src/events/intersect.ts @@ -0,0 +1,19 @@ +/** Emitted when an element's intersection state changes. */ +export class WaIntersectEvent extends Event { + readonly detail?: WaIntersectEventDetail; + + constructor(detail?: WaIntersectEventDetail) { + super('wa-intersect', { bubbles: false, cancelable: false, composed: true }); + this.detail = detail; + } +} + +interface WaIntersectEventDetail { + entry?: IntersectionObserverEntry; +} + +declare global { + interface GlobalEventHandlersEventMap { + 'wa-intersect': WaIntersectEvent; + } +}