mirror of
https://github.com/shoelace-style/webawesome.git
synced 2026-01-12 04:09:12 +00:00
Add intersection observer component (#1406)
* add intersection observer component * remove prefix * update description
This commit is contained in:
@@ -98,6 +98,7 @@
|
||||
<li><a href="/docs/components/icon/">Icon</a></li>
|
||||
<li><a href="/docs/components/include/">Include</a></li>
|
||||
<li><a href="/docs/components/input/">Input</a></li>
|
||||
<li><a href="/docs/components/intersection-observer">Intersection Observer</a></li>
|
||||
<li><a href="/docs/components/mutation-observer/">Mutation Observer</a></li>
|
||||
<li><a href="/docs/components/popover/">Popover</a></li>
|
||||
<li><a href="/docs/components/popup/">Popup</a></li>
|
||||
|
||||
@@ -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}
|
||||
<div id="intersection__overview">
|
||||
<wa-intersection-observer threshold="1" intersect-class="visible">
|
||||
<div class="box"><wa-icon name="bulb"></wa-icon></div>
|
||||
</wa-intersection-observer>
|
||||
</div>
|
||||
|
||||
<small>Scroll to see the element intersect at 100% visibility</small>
|
||||
|
||||
<style>
|
||||
/* Container styles */
|
||||
#intersection__overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
height: 300px;
|
||||
border: solid 2px var(--wa-color-surface-border);
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
|
||||
/* Spacers to demonstrate scrolling */
|
||||
&::before {
|
||||
content: '';
|
||||
height: 260px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
height: 260px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Box styles */
|
||||
.box {
|
||||
flex-shrink: 0;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background-color: var(--wa-color-neutral-fill-normal);
|
||||
color: var(--wa-color-neutral-10);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-inline: auto;
|
||||
transition: all 50ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
|
||||
wa-icon {
|
||||
font-size: 3rem;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
&.visible {
|
||||
background-color: var(--wa-color-brand-60);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
+ small {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
:::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
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<wa-intersection-observer>
|
||||
<div class="box">Box 1</div>
|
||||
<div class="box">Box 2</div>
|
||||
<div class="box">Box 3</div>
|
||||
</wa-intersection-observer>
|
||||
</div>
|
||||
```
|
||||
|
||||
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
|
||||
<div id="scroll-container">
|
||||
<wa-intersection-observer root="scroll-container" root-margin="50px 0px"> ... </wa-intersection-observer>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 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
|
||||
<wa-intersection-observer threshold="0 0.25 0.5 0.75 1"> ... </wa-intersection-observer>
|
||||
```
|
||||
|
||||
### 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}
|
||||
<div id="intersection__classes">
|
||||
<wa-intersection-observer threshold="0.5" intersect-class="visible" root="intersection__classes">
|
||||
<div class="box fade">Fade In</div>
|
||||
<div class="box slide">Slide In</div>
|
||||
<div class="box scale">Scale & Rotate</div>
|
||||
<div class="box bounce">Bounce</div>
|
||||
</wa-intersection-observer>
|
||||
</div>
|
||||
|
||||
<small>Scroll to see elements transition at 50% visibility</small>
|
||||
|
||||
<style>
|
||||
/* Container styles */
|
||||
#intersection__classes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
height: 300px;
|
||||
border: solid 2px var(--wa-color-surface-border);
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
|
||||
/* Spacers to demonstrate scrolling */
|
||||
&::before {
|
||||
content: '';
|
||||
height: 260px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
height: 260px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
+ small {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
|
||||
/* Shared box styles */
|
||||
.box {
|
||||
flex-shrink: 0;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: white;
|
||||
opacity: 0;
|
||||
padding: 2rem;
|
||||
margin-inline: auto;
|
||||
|
||||
/* Fade */
|
||||
&.fade {
|
||||
background: var(--wa-color-brand-fill-loud);
|
||||
color: var(--wa-color-brand-on-loud);
|
||||
transform: translateY(30px);
|
||||
transition: all 0.6s ease;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Slide */
|
||||
&.slide {
|
||||
background: var(--wa-color-brand-fill-loud);
|
||||
color: var(--wa-color-brand-on-loud);
|
||||
transform: translateX(-50px);
|
||||
transition: all 0.5s ease;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Scale */
|
||||
&.scale {
|
||||
background: var(--wa-color-brand-fill-loud);
|
||||
color: var(--wa-color-brand-on-loud);
|
||||
transform: scale(0.6) rotate(-15deg);
|
||||
transition: all 0.7s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Bounce In and Out */
|
||||
&.bounce {
|
||||
background: var(--wa-color-brand-fill-loud);
|
||||
color: var(--wa-color-brand-on-loud);
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
transition: none;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
animation: bounceIn 0.8s cubic-bezier(0.68, -0.55, 0.265, 1.55) forwards;
|
||||
}
|
||||
|
||||
&:not(.visible) {
|
||||
animation: bounceOut 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55) forwards;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounceIn {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
65% {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
80% {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
90% {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounceOut {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
20% {
|
||||
transform: scale(1.02);
|
||||
opacity: 1;
|
||||
}
|
||||
40% {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.8;
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.6;
|
||||
}
|
||||
80% {
|
||||
transform: scale(0.95);
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
@@ -18,6 +18,7 @@ Components with the <wa-badge variant="warning">Experimental</wa-badge> badge sh
|
||||
- Removed the `fixed-width` attribute as it's now the default behavior
|
||||
- 🚨 BREAKING: Renamed the `icon-position` attribute to `icon-placement` in `<wa-details>` [discuss:1340]
|
||||
- 🚨 BREAKING: Removed the `size` attribute from `<wa-button-group>` 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 `<wa-intersection-observer>` component
|
||||
- Added the Hindi translation [pr:1307]
|
||||
- Added `--show-duration` and `--hide-duration` to `<wa-select>` [issue:1281]
|
||||
- Fixed incorrectly named exported tooltip parts in `<wa-slider>` [pr:1277]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { expect, fixture, html } from '@open-wc/testing';
|
||||
|
||||
describe('<wa-intersection-observer>', () => {
|
||||
it('should render a component', async () => {
|
||||
const el = await fixture(html` <wa-intersection-observer></wa-intersection-observer> `);
|
||||
|
||||
expect(el).to.exist;
|
||||
});
|
||||
});
|
||||
@@ -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, boolean>();
|
||||
|
||||
/** 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` <slot @slotchange=${this.handleSlotChange}></slot> `;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'wa-intersection-observer': WaIntersectionObserver;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
19
packages/webawesome/src/events/intersect.ts
Normal file
19
packages/webawesome/src/events/intersect.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user