From 4523243414817418e7abb47e93bf2ba217daa058 Mon Sep 17 00:00:00 2001 From: mrednic Date: Fri, 16 Feb 2024 15:58:59 +0100 Subject: [PATCH] feat: resize observer store in core package --- .../resizeObserver/resizeObserver.route.ts | 54 +++++++++ angular/headless/src/utils/stores.ts | 4 +- .../resizeobserver/resizeobserver.scss | 9 ++ core/src/components/slider/slider.ts | 29 ++--- core/src/index.ts | 1 + core/src/services/resizeObserver.spec.ts | 108 ++++++++++++++++++ core/src/services/resizeObserver.ts | 41 +++++++ demo/src/lib/layout/Sample.svelte | 4 +- demo/src/lib/layout/iframe.ts | 45 ++++---- demo/src/lib/markdown/samples.ts | 2 + docs/04-Services/03-Resize-Observer.md | 44 +++++++ e2e/demo-po/resizeObserver.po.ts | 35 ++++++ e2e/resizeObserver/resizeObserver.e2e-spec.ts | 36 ++++++ .../resizeobserver-resizeobserver.html | 52 +++++++++ .../resizeObserver/ResizeObserver.route.tsx | 49 ++++++++ .../ResizeObserver.route.svelte | 36 ++++++ 16 files changed, 505 insertions(+), 44 deletions(-) create mode 100644 angular/demo/src/app/samples/resizeObserver/resizeObserver.route.ts create mode 100644 common/samples/resizeobserver/resizeobserver.scss create mode 100644 core/src/services/resizeObserver.spec.ts create mode 100644 core/src/services/resizeObserver.ts create mode 100644 docs/04-Services/03-Resize-Observer.md create mode 100644 e2e/demo-po/resizeObserver.po.ts create mode 100644 e2e/resizeObserver/resizeObserver.e2e-spec.ts create mode 100644 e2e/samplesMarkup.chromium.e2e-spec.ts-snapshots/resizeobserver-resizeobserver.html create mode 100644 react/demo/src/app/samples/resizeObserver/ResizeObserver.route.tsx create mode 100644 svelte/demo/src/app/samples/resizeObserver/ResizeObserver.route.svelte diff --git a/angular/demo/src/app/samples/resizeObserver/resizeObserver.route.ts b/angular/demo/src/app/samples/resizeObserver/resizeObserver.route.ts new file mode 100644 index 0000000000..d4ef4293c6 --- /dev/null +++ b/angular/demo/src/app/samples/resizeObserver/resizeObserver.route.ts @@ -0,0 +1,54 @@ +import {AgnosUIAngularModule, createResizeObserver, toAngularSignal} from '@agnos-ui/angular'; +import {UseDirective} from '@agnos-ui/angular-headless'; +import {ChangeDetectionStrategy, Component, computed} from '@angular/core'; + +@Component({ + standalone: true, + imports: [UseDirective, AgnosUIAngularModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ + +
+ Textarea content height: {{ observedHeight$() }}px +
+ + +
+ `, + styles: "@import '@agnos-ui/common/samples/resizeobserver/resizeobserver.scss';", +}) +export default class ResizeObserverComponent { + heightValue = 180; + + readonly resizeObserver = createResizeObserver(); + + readonly resizeDirective = this.resizeObserver.directive; + + readonly dimensions$ = toAngularSignal(this.resizeObserver.dimensions$); + + readonly observedHeight$ = computed(() => this.dimensions$()?.contentRect?.height); + + increaseHeight() { + this.heightValue = (this.observedHeight$() || 0) + 50; + } + + decreaseHeight() { + this.heightValue = this.observedHeight$() ? this.observedHeight$()! - 50 : 0; + } +} diff --git a/angular/headless/src/utils/stores.ts b/angular/headless/src/utils/stores.ts index fdc201d8a3..6506eafec8 100644 --- a/angular/headless/src/utils/stores.ts +++ b/angular/headless/src/utils/stores.ts @@ -16,7 +16,9 @@ export * from '@agnos-ui/core/utils/stores'; */ export const toAngularSignal = (tansuSignal: ReadableSignal): Signal => { const zoneWrapper = inject(ZoneWrapper); - const res = signal(undefined as any as T); + // The equality of objects from 2 sequential emissions is already checked in tansu signal. + // Here we'll always emit the value received from tansu signal, therefor the equality function + const res = signal(undefined as any as T, {equal: () => false}); const subscription = zoneWrapper.outsideNgZone(tansuSignal.subscribe)((value) => { res.set(value); zoneWrapper.planNgZoneRun(); diff --git a/common/samples/resizeobserver/resizeobserver.scss b/common/samples/resizeobserver/resizeobserver.scss new file mode 100644 index 0000000000..2c326e03ff --- /dev/null +++ b/common/samples/resizeobserver/resizeobserver.scss @@ -0,0 +1,9 @@ +div.demo-resize-observer { + #resizable { + resize: both; + width: 400px; + } + .fontsize { + font-size: x-large; + } +} diff --git a/core/src/components/slider/slider.ts b/core/src/components/slider/slider.ts index a7158c9d34..90030661e2 100644 --- a/core/src/components/slider/slider.ts +++ b/core/src/components/slider/slider.ts @@ -1,12 +1,13 @@ import type {WritableSignal} from '@amadeus-it-group/tansu'; -import {computed, derived, writable} from '@amadeus-it-group/tansu'; +import {computed, writable} from '@amadeus-it-group/tansu'; import type {WidgetsCommonPropsAndState} from '../commonProps'; -import {createStoreDirective, directiveSubscribe, mergeDirectives} from '../../utils/directive'; +import {createStoreDirective, mergeDirectives} from '../../utils/directive'; import type {ConfigValidator, Directive, PropsConfig, SlotContent, Widget, WidgetSlotContext} from '../../types'; import {noop} from '../../utils/internal/func'; import {getDecimalPrecision} from '../../utils/internal/math'; import {bindableProp, stateStores, writablesForProps} from '../../utils/stores'; import {typeArray, typeBoolean, typeFunction, typeNumber, typeNumberInRangeFactory} from '../../utils/writables'; +import {createResizeObserver} from '../../services/resizeObserver'; export type SliderContext = WidgetSlotContext; export type SliderSlotLabelContext = SliderContext & {value: number}; @@ -421,26 +422,12 @@ export function createSlider(config?: PropsConfig): SliderWidget { const {directive: sliderDirective, element$: sliderDom$} = createStoreDirective(); const {directive: minLabelDirective, element$: minLabelDom$} = createStoreDirective(); const {directive: maxLabelDirective, element$: maxLabelDom$} = createStoreDirective(); + const {directive: resizeDirective, dimensions$} = createResizeObserver(); - const sliderResized$ = derived( - sliderDom$, - (sliderDom, set) => { - if (!sliderDom) { - set({}); - return; - } - const resizeObserver = new ResizeObserver(() => { - set({}); - }); - resizeObserver.observe(sliderDom); - return () => resizeObserver.disconnect(); - }, - {}, - ); const updateSliderSize$ = writable({}); const sliderDomRect$ = computed( () => { - sliderResized$(); + dimensions$(); updateSliderSize$(); return sliderDom$()?.getBoundingClientRect() ?? ({} as DOMRect); }, @@ -450,7 +437,7 @@ export function createSlider(config?: PropsConfig): SliderWidget { ); const minLabelDomRect$ = computed( () => { - sliderResized$(); + dimensions$(); updateSliderSize$(); return minLabelDom$()?.getBoundingClientRect() ?? ({} as DOMRect); }, @@ -460,7 +447,7 @@ export function createSlider(config?: PropsConfig): SliderWidget { ); const maxLabelDomRect$ = computed( () => { - sliderResized$(); + dimensions$(); updateSliderSize$(); return maxLabelDom$()?.getBoundingClientRect() ?? ({} as DOMRect); }, @@ -636,7 +623,7 @@ export function createSlider(config?: PropsConfig): SliderWidget { patch, api: {}, directives: { - sliderDirective: mergeDirectives(sliderDirective, directiveSubscribe(sliderResized$)), + sliderDirective: mergeDirectives(sliderDirective, resizeDirective), minLabelDirective, maxLabelDirective, }, diff --git a/core/src/index.ts b/core/src/index.ts index 794187242c..634b307ad9 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -22,6 +22,7 @@ export * from './services/focustrack'; export * from './services/intersection'; export * from './services/navManager'; export * from './services/portal'; +export * from './services/resizeObserver'; export * from './services/siblingsInert'; // services transitions diff --git a/core/src/services/resizeObserver.spec.ts b/core/src/services/resizeObserver.spec.ts new file mode 100644 index 0000000000..2ced59e6af --- /dev/null +++ b/core/src/services/resizeObserver.spec.ts @@ -0,0 +1,108 @@ +import {describe, expect, test, vi, vitest} from 'vitest'; +import {createResizeObserver} from './resizeObserver'; + +describe(`ResizeObserver service`, () => { + test(`Should have original dimensions as intial observed values`, async () => { + const observedElement = document.body.appendChild(document.createElement('textarea')); + observedElement.style.height = '100px'; + observedElement.style.width = '100px'; + const {directive, dimensions$} = createResizeObserver(); + + let emits = 0; + let dimensions; + const unsubscribe = dimensions$.subscribe((dim) => { + emits++; + dimensions = dim; + }); + + // store first value + await vi.waitUntil(() => emits === 1); + expect(dimensions).toBeUndefined(); + + const directiveApplied = directive(observedElement); + // element default values + await vi.waitUntil(() => emits === 2); + expect(dimensions!.contentRect).toMatchObject({width: 100, height: 100}); + // cleanup + observedElement.parentElement?.removeChild(observedElement); + unsubscribe(); + directiveApplied?.destroy?.(); + }); + + test(`Should give the dimensions of observed element`, async () => { + const observedElement = document.body.appendChild(document.createElement('div')); + observedElement.style.height = '100px'; + observedElement.style.width = '100px'; + const {directive, dimensions$} = createResizeObserver(); + let emits = 0; + let dimensions; + const unsubscribe = dimensions$.subscribe((dim) => { + emits++; + dimensions = dim; + }); + // store first value + await vi.waitUntil(() => emits === 1); + expect(dimensions).toBeUndefined(); + + const directiveApplied = directive(observedElement); + // element default values + await vi.waitUntil(() => emits === 2); + expect(dimensions!.contentRect).toMatchObject({width: 100, height: 100}); + + // trigger first resize + observedElement.style.height = '200px'; + observedElement.style.width = '200px'; + await vi.waitUntil(() => emits === 3); + expect(dimensions!.contentRect).toMatchObject({width: 200, height: 200}); + + // trigger second resize + observedElement.style.width = '300px'; + await vi.waitUntil(() => emits === 4); + expect(dimensions!.contentRect).toMatchObject({width: 300, height: 200}); + + //cleanup + observedElement.parentElement?.removeChild(observedElement); + directiveApplied?.destroy?.(); + unsubscribe(); + }); + + test(`Should keep current element when trying to add new one to the directive`, async () => { + const consoleErrorSpy = vitest.spyOn(console, 'error').mockImplementation(() => {}); + const textarea = document.body.appendChild(document.createElement('textarea')); + + textarea.style.height = '100px'; + textarea.style.width = '100px'; + + const {directive, dimensions$} = createResizeObserver(); + + const div = document.body.appendChild(document.createElement('div')); + div.style.height = '200px'; + + let emits = 0; + let dimensions; + const unsubscribe = dimensions$.subscribe((dim) => { + emits++; + dimensions = dim; + }); + // store first value + await vi.waitUntil(() => emits === 1); + expect(dimensions).toBeUndefined(); + + const directiveTextAreaApplied = directive(textarea); + const directiveDivApplied = directive(div); + + expect(consoleErrorSpy).toHaveBeenCalledOnce(); + expect(consoleErrorSpy.mock.calls[0][0]).toBe('The directive cannot be used on multiple elements.'); + + //textarea default values + await vi.waitUntil(() => emits === 2); + expect(dimensions!.contentRect).toMatchObject({width: 100, height: 100}); + + // cleanup + textarea.parentElement?.removeChild(textarea); + div.parentElement?.removeChild(div); + unsubscribe(); + directiveDivApplied?.destroy?.(); + directiveTextAreaApplied?.destroy?.(); + }); +}); diff --git a/core/src/services/resizeObserver.ts b/core/src/services/resizeObserver.ts new file mode 100644 index 0000000000..6a334ff87b --- /dev/null +++ b/core/src/services/resizeObserver.ts @@ -0,0 +1,41 @@ +import type {ReadableSignal} from '@amadeus-it-group/tansu'; +import {derived} from '@amadeus-it-group/tansu'; +import {createStoreDirective} from '../utils/directive'; +import {noop} from '../utils/internal/func'; + +/** + * Create a resize observer object + * @returns An object containing the store with the dimentions of observed element (ResizeObserverEntry), the directive to be applied to the html element to be observed + */ +export const createResizeObserver = () => { + const {element$, directive} = createStoreDirective(); + + const observedElement$ = derived>( + element$, + (element, set) => { + if (element === null) { + return noop; + } + + const observer = new ResizeObserver((entries) => { + set(entries[0]); + }); + + observer.observe(element); + + return () => observer?.disconnect(); + }, + undefined, + ); + + return { + /** + * Store which contains the dimensions of the observed element (ResizeObserverEntry type) + * See the [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) + */ + dimensions$: observedElement$, + + /** Directive to be attached to html element in order to listen to resize events */ + directive, + }; +}; diff --git a/demo/src/lib/layout/Sample.svelte b/demo/src/lib/layout/Sample.svelte index bc61c4a2a1..85105d0c08 100644 --- a/demo/src/lib/layout/Sample.svelte +++ b/demo/src/lib/layout/Sample.svelte @@ -79,7 +79,7 @@ $: sampleBaseUrl = `${$pathToRoot$}${$selectedFramework$}/samples${complementaryUrl}/#/${path}`; $: sampleUrl = sampleBaseUrl + (urlParameters ? `#${JSON.stringify(urlParameters)}` : ''); - const {showSpinner$, handler} = createIframeHandler(height, !noresize); + const {showSpinner$, handlerDirective} = createIframeHandler(height, !noresize);
@@ -91,7 +91,7 @@
{/if} -