Skip to content

Commit

Permalink
feat: resize observer store in core package
Browse files Browse the repository at this point in the history
  • Loading branch information
mrednic-1A authored and quentinderoubaix committed Mar 5, 2024
1 parent d0065e6 commit 4523243
Show file tree
Hide file tree
Showing 16 changed files with 505 additions and 44 deletions.
Original file line number Diff line number Diff line change
@@ -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: `
<div class="demo-resize-observer">
<label for="resizable">Resizable textarea:</label>
<textarea
[auUse]="resizeDirective"
name="resizable"
id="resizable"
rows="6"
cols="50"
class="form-control"
[class.fontsize]="observedHeight$() && observedHeight$()! > 200"
[style.height.px]="heightValue"
>
This simple example is using the resizeObserver feature from @agnos-ui/core and displays the height of the textarea below it.
Modify the height to more than 200 px and see the font size changing.
</textarea
>
<div>
Textarea content height: <span id="dynamic-height">{{ observedHeight$() }}</span
>px
</div>
<button type="button" class="btn btn-primary m-2" id="decreaseHeight" (click)="decreaseHeight()">Height --</button>
<button type="button" class="btn btn-primary m-2" id="increaseHeight" (click)="increaseHeight()">Height ++</button>
</div>
`,
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;
}
}
4 changes: 3 additions & 1 deletion angular/headless/src/utils/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ export * from '@agnos-ui/core/utils/stores';
*/
export const toAngularSignal = <T>(tansuSignal: ReadableSignal<T>): Signal<T> => {
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();
Expand Down
9 changes: 9 additions & 0 deletions common/samples/resizeobserver/resizeobserver.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
div.demo-resize-observer {
#resizable {
resize: both;
width: 400px;
}
.fontsize {
font-size: x-large;
}
}
29 changes: 8 additions & 21 deletions core/src/components/slider/slider.ts
Original file line number Diff line number Diff line change
@@ -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<SliderWidget>;
export type SliderSlotLabelContext = SliderContext & {value: number};
Expand Down Expand Up @@ -421,26 +422,12 @@ export function createSlider(config?: PropsConfig<SliderProps>): 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);
},
Expand All @@ -450,7 +437,7 @@ export function createSlider(config?: PropsConfig<SliderProps>): SliderWidget {
);
const minLabelDomRect$ = computed(
() => {
sliderResized$();
dimensions$();
updateSliderSize$();
return minLabelDom$()?.getBoundingClientRect() ?? ({} as DOMRect);
},
Expand All @@ -460,7 +447,7 @@ export function createSlider(config?: PropsConfig<SliderProps>): SliderWidget {
);
const maxLabelDomRect$ = computed(
() => {
sliderResized$();
dimensions$();
updateSliderSize$();
return maxLabelDom$()?.getBoundingClientRect() ?? ({} as DOMRect);
},
Expand Down Expand Up @@ -636,7 +623,7 @@ export function createSlider(config?: PropsConfig<SliderProps>): SliderWidget {
patch,
api: {},
directives: {
sliderDirective: mergeDirectives(sliderDirective, directiveSubscribe(sliderResized$)),
sliderDirective: mergeDirectives(sliderDirective, resizeDirective),
minLabelDirective,
maxLabelDirective,
},
Expand Down
1 change: 1 addition & 0 deletions core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
108 changes: 108 additions & 0 deletions core/src/services/resizeObserver.spec.ts
Original file line number Diff line number Diff line change
@@ -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?.();
});
});
41 changes: 41 additions & 0 deletions core/src/services/resizeObserver.ts
Original file line number Diff line number Diff line change
@@ -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<ResizeObserverEntry | undefined, ReadableSignal<HTMLElement | null>>(
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,
};
};
4 changes: 2 additions & 2 deletions demo/src/lib/layout/Sample.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
</script>

<div class="mb-4 py-2 px-0 px-sm-3">
Expand All @@ -91,7 +91,7 @@
</div>
</div>
{/if}
<iframe class="demo-sample d-block" use:iframeSrc={sampleUrl} {title} use:handler={sampleBaseUrl} />
<iframe class="demo-sample d-block" use:iframeSrc={sampleUrl} {title} use:handlerDirective={sampleBaseUrl} />
</div>
{#if showButtons}
<div class="btn-toolbar border border-top-0 d-flex align-items-center p-1" role="toolbar" aria-label="Toolbar with button groups">
Expand Down
Loading

0 comments on commit 4523243

Please sign in to comment.