diff --git a/angular/demo/src/app/samples/floatingUI/floatingUI.route.ts b/angular/demo/src/app/samples/floatingUI/floatingUI.route.ts new file mode 100644 index 0000000000..3f0cbe8fce --- /dev/null +++ b/angular/demo/src/app/samples/floatingUI/floatingUI.route.ts @@ -0,0 +1,54 @@ +import {AgnosUIAngularModule, createFloatingUI, floatingUI, toAngularSignal} from '@agnos-ui/angular'; +import {ChangeDetectionStrategy, Component} from '@angular/core'; + +const scrollToMiddle = (element: HTMLElement) => { + element.scrollTo({left: 326, top: 420}); +}; + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: `
+ + @if (displayPopover) { + + } +
`, + + styles: "@import '@agnos-ui/common/samples/floatingui/floatingui.scss';", +}) +export default class FloatingUIComponent { + displayPopover = true; + + floatingUI = createFloatingUI({ + props: { + arrowOptions: { + padding: 6, + }, + computePositionOptions: { + middleware: [ + floatingUI.offset(10), + floatingUI.autoPlacement(), + floatingUI.shift({ + padding: 5, + }), + floatingUI.hide(), + ], + }, + }, + }); + floatingUIState = toAngularSignal(this.floatingUI.state$); + scrollToMiddle = scrollToMiddle; +} diff --git a/common/samples/floatingui/floatingui.scss b/common/samples/floatingui/floatingui.scss new file mode 100644 index 0000000000..33ef5f6bbe --- /dev/null +++ b/common/samples/floatingui/floatingui.scss @@ -0,0 +1,12 @@ +div.demo-floatingui { + width: 500px; + height: 200px; + + button { + margin: 500px; + width: 150px; + } + .popover { + width: 250px; + } +} diff --git a/core/lib/package.json b/core/lib/package.json index ed8888387e..136ff12bdb 100644 --- a/core/lib/package.json +++ b/core/lib/package.json @@ -16,7 +16,8 @@ } }, "dependencies": { - "@amadeus-it-group/tansu": "0.0.23" + "@amadeus-it-group/tansu": "0.0.23", + "@floating-ui/dom": "^1.5.3" }, "sideEffects": false } diff --git a/core/lib/services/floatingUI.ts b/core/lib/services/floatingUI.ts new file mode 100644 index 0000000000..18d1007c1c --- /dev/null +++ b/core/lib/services/floatingUI.ts @@ -0,0 +1,131 @@ +import {computed, derived} from '@amadeus-it-group/tansu'; +import type {ArrowOptions, AutoUpdateOptions, ComputePositionConfig, ComputePositionReturn, Derivable} from '@floating-ui/dom'; +import {arrow, autoUpdate, computePosition} from '@floating-ui/dom'; +import {createStoreDirective, directiveSubscribe, mergeDirectives} from './directiveUtils'; +import {stateStores, writablesForProps, type PropsConfig} from './stores'; + +import * as floatingUI from '@floating-ui/dom'; +import {promiseStoreToValueStore} from './promiseStoreUtils'; +export {floatingUI}; + +export interface FloatingUIProps { + /** + * Options to use when calling computePosition from Floating UI + */ + computePositionOptions: ComputePositionConfig; + + /** + * Options to use when calling autoUpdate from Floating UI + */ + autoUpdateOptions: AutoUpdateOptions; + + /** + * Options to use when calling the arrow middleware from Floating UI + */ + arrowOptions: Omit | Derivable>; +} + +const defaultConfig: FloatingUIProps = { + computePositionOptions: {}, + autoUpdateOptions: {}, + arrowOptions: {}, +}; + +export const createFloatingUI = (propsConfig?: PropsConfig) => { + const [{autoUpdateOptions$, computePositionOptions$: computePositionInputOptions$, arrowOptions$: arrowInputOptions$}, patch] = writablesForProps( + defaultConfig, + propsConfig, + ); + + const {directive: floatingDirective, element$: floatingElement$} = createStoreDirective(); + const {directive: referenceDirective, element$: referenceElement$} = createStoreDirective(); + const {directive: arrowDirective, element$: arrowElement$} = createStoreDirective(); + + const arrowOptions$ = computed((): null | ArrowOptions | Derivable => { + const arrowElement = arrowElement$(); + if (!arrowElement) { + return null; + } + const arrowInputOptions = arrowInputOptions$(); + return typeof arrowInputOptions === 'function' + ? (state) => ({...arrowInputOptions(state), element: arrowElement}) + : {...arrowInputOptions, element: arrowElement}; + }); + + const computePositionOptions$ = computed(() => { + let options = computePositionInputOptions$(); + const arrowOptions = arrowOptions$(); + if (arrowOptions) { + options = { + ...options, + middleware: [...(options.middleware ?? []), arrow(arrowOptions)], + }; + } + return options; + }); + + const promisePosition$ = derived( + [floatingElement$, referenceElement$, computePositionOptions$, autoUpdateOptions$], + ([floatingElement, referenceElement, computePositionOptions, autoUpdateOptions], set) => { + if (floatingElement && referenceElement) { + const clean = autoUpdate( + referenceElement, + floatingElement, + () => { + set(computePosition(referenceElement, floatingElement, computePositionOptions)); + }, + autoUpdateOptions, + ); + return () => { + set(null); + clean(); + }; + } + return undefined; + }, + null as null | Promise, + ); + const position$ = promiseStoreToValueStore(promisePosition$, null); + + const placement$ = computed(() => position$()?.placement); + const middlewareData$ = computed(() => position$()?.middlewareData); + const x$ = computed(() => position$()?.x); + const y$ = computed(() => position$()?.y); + const strategy$ = computed(() => position$()?.strategy); + const arrowX$ = computed(() => middlewareData$()?.arrow?.x); + const arrowY$ = computed(() => middlewareData$()?.arrow?.y); + + const floatingStyleApplyAction$ = computed(() => { + const floatingElement = floatingElement$(); + if (floatingElement) { + floatingElement.style.left = `${x$() ?? 0}px`; + floatingElement.style.top = `${y$() ?? 0}px`; + } + }); + + const arrowStyleApplyAction$ = computed(() => { + const arrowElement = arrowElement$(); + if (arrowElement) { + const arrowX = arrowX$(); + const arrowY = arrowY$(); + arrowElement.style.left = arrowX != null ? `${arrowX}px` : ''; + arrowElement.style.top = arrowY != null ? `${arrowY}px` : ''; + } + }); + + return { + patch, + ...stateStores({ + x$, + y$, + strategy$, + placement$, + middlewareData$, + }), + directives: { + referenceDirective, + floatingDirective: mergeDirectives(floatingDirective, directiveSubscribe(floatingStyleApplyAction$)), + arrowDirective: mergeDirectives(arrowDirective, directiveSubscribe(arrowStyleApplyAction$)), + }, + }; +}; diff --git a/core/lib/services/index.ts b/core/lib/services/index.ts index 2b64badf11..a217340e0e 100644 --- a/core/lib/services/index.ts +++ b/core/lib/services/index.ts @@ -8,3 +8,4 @@ export * from './writables'; export * from './navManager'; export * from './isFocusable'; export * from './domUtils'; +export * from './floatingUI'; diff --git a/core/lib/services/promiseStoreUtils.spec.ts b/core/lib/services/promiseStoreUtils.spec.ts new file mode 100644 index 0000000000..2bb027c298 --- /dev/null +++ b/core/lib/services/promiseStoreUtils.spec.ts @@ -0,0 +1,215 @@ +import {writable} from '@amadeus-it-group/tansu'; +import {describe, expect, test} from 'vitest'; +import type {PromiseState} from './promiseStoreUtils'; +import {promisePending, promiseStateStore, promiseStoreToPromiseStateStore, promiseStoreToValueStore} from './promiseStoreUtils'; + +const promiseWithResolve = () => { + let resolve: (value: T | Promise) => void; + let reject: (value: any) => void; + const promise = new Promise((a, b) => { + resolve = a; + reject = b; + }); + return {promise, resolve: resolve!, reject: reject!}; +}; + +const wrapInThenable = (promise: PromiseLike): PromiseLike => { + return { + then(resolve, reject) { + return wrapInThenable(promise.then(resolve, reject)); + }, + }; +}; +wrapInThenable.toString = () => 'custom thenable'; +const identity = (a: T) => a; +identity.toString = () => 'promise'; + +describe('promiseStateStore', () => { + const testWithValue = (value: T) => { + test(`test with simple value ${value}`, () => { + const store = promiseStateStore(value); + const storeValue = store(); + expect(storeValue.status).toEqual('fulfilled'); + expect((storeValue as PromiseFulfilledResult).value).toBe(value); + }); + + for (const promiseWrapper of [identity, wrapInThenable]) { + test(`test with ${promiseWrapper} resolving to ${value}`, async () => { + const {promise, resolve} = promiseWithResolve(); + const thenable = promiseWrapper(promise); + const store = promiseStateStore(thenable); + expect(store()).toBe(promisePending); + resolve(value); + expect(await thenable).toBe(value); + let storeValue = store(); + expect(storeValue.status).toBe('fulfilled'); + expect((storeValue as PromiseFulfilledResult).value).toBe(value); + storeValue = promiseStateStore(thenable)(); + expect(storeValue.status).toBe('fulfilled'); + expect((storeValue as PromiseFulfilledResult).value).toBe(value); + }); + + test(`test with ${promiseWrapper} throwing ${value}`, async () => { + const {promise, reject} = promiseWithResolve(); + const thenable = promiseWrapper(promise); + const store = promiseStateStore(thenable); + expect(store()).toBe(promisePending); + reject(value); + try { + await thenable; + expect.fail('the promise should be rejected'); + } catch (error) { + expect(error).toBe(value); + // should pass here + } + let storeValue = store(); + expect(storeValue.status).toBe('rejected'); + expect((storeValue as PromiseRejectedResult).reason).toBe(value); + storeValue = promiseStateStore(thenable)(); + expect(storeValue.status).toBe('rejected'); + expect((storeValue as PromiseRejectedResult).reason).toBe(value); + }); + } + }; + + testWithValue(0); + testWithValue(1); + testWithValue('ok'); + testWithValue(true); + testWithValue(null); + testWithValue(false); + testWithValue(NaN); + testWithValue(() => {}); + testWithValue({}); +}); + +describe('promiseStoreToPromiseStateStore', () => { + test('Basic functionalities', async () => { + const firstResolvedValue = {}; + const promiseStore$ = writable(Promise.resolve(firstResolvedValue)); + const promiseStateStore$ = promiseStoreToPromiseStateStore(promiseStore$); + let state = promiseStateStore$(); + expect(state).toBe(promisePending); + await 0; + state = promiseStateStore$(); + expect(state.status).toBe('fulfilled'); + expect((state as PromiseFulfilledResult).value).toBe(firstResolvedValue); + }); + + test('second promise resolving before the first one', async () => { + const first = promiseWithResolve(); + const second = promiseWithResolve(); + const states: PromiseState[] = []; + const promiseStore$ = writable(first.promise); + const promiseStateStore$ = promiseStoreToPromiseStateStore(promiseStore$); + promiseStateStore$.subscribe((state) => { + states.push(state); + }); + expect(states.length).toBe(1); + expect(states[0]).toBe(promisePending); + await 0; + promiseStore$.set(second.promise); + expect(states.length).toBe(1); + second.resolve('second'); + await 0; + expect(states.length).toBe(2); + expect(states[1]).toEqual({status: 'fulfilled', value: 'second'}); + first.resolve('first'); // the first promise is ignored + await 0; + expect(states.length).toBe(2); + await 0; + expect(states.length).toBe(2); + // now let's come back to the first promise: + promiseStore$.set(first.promise); + // the result should be synchronous: + expect(states.length).toBe(3); + expect(states[2]).toEqual({status: 'fulfilled', value: 'first'}); + }); + + test('promises resolving with the same value', async () => { + const firstPromise = Promise.resolve('value'); + const secondPromise = Promise.resolve('value'); + expect(firstPromise).not.toBe(secondPromise); // different objects but same resolved value + const states: PromiseState[] = []; + const promiseStore$ = writable(firstPromise); + const promiseStateStore$ = promiseStoreToPromiseStateStore(promiseStore$); + promiseStateStore$.subscribe((state) => { + states.push(state); + }); + expect(states.length).toBe(1); + expect(states[0]).toBe(promisePending); + await 0; + expect(states.length).toBe(2); + expect(states[1]).toEqual({status: 'fulfilled', value: 'value'}); + promiseStore$.set(secondPromise); + // the promise status cannot be known synchronously + expect(states.length).toBe(3); + expect(states[2]).toBe(promisePending); + await 0; + expect(states.length).toBe(4); + expect(states[3]).toEqual({status: 'fulfilled', value: 'value'}); + // now let's come back to the first promise, which has the same value as the second: + promiseStore$.set(firstPromise); + expect(states.length).toBe(4); // no change + await 0; + expect(states.length).toBe(4); // no change + }); + + test('promises rejected with the same reason', async () => { + const firstPromise = Promise.reject('reason'); + const secondPromise = Promise.reject('reason'); + expect(firstPromise).not.toBe(secondPromise); // different objects but same rejected value + const states: PromiseState[] = []; + const promiseStore$ = writable(firstPromise); + const promiseStateStore$ = promiseStoreToPromiseStateStore(promiseStore$); + promiseStateStore$.subscribe((state) => { + states.push(state); + }); + expect(states.length).toBe(1); + expect(states[0]).toBe(promisePending); + await 0; + expect(states.length).toBe(2); + expect(states[1]).toEqual({status: 'rejected', reason: 'reason'}); + promiseStore$.set(secondPromise); + // the promise status cannot be known synchronously + expect(states.length).toBe(3); + expect(states[2]).toBe(promisePending); + await 0; + expect(states.length).toBe(4); + expect(states[3]).toEqual({status: 'rejected', reason: 'reason'}); + // now let's come back to the first promise, which has the same value as the second: + promiseStore$.set(firstPromise); + expect(states.length).toBe(4); // no change + await 0; + expect(states.length).toBe(4); // no change + }); +}); + +describe('promiseStoreToValueStore', () => { + test('Basic functionalities', async () => { + const firstPromise = Promise.resolve('value'); + const states: string[] = []; + const promiseStore$ = writable(firstPromise); + const promiseStateStore$ = promiseStoreToValueStore(promiseStore$, 'initial'); + promiseStateStore$.subscribe((state) => { + states.push(state); + }); + expect(states.length).toBe(1); + expect(states[0]).toBe('initial'); + await 0; + expect(states.length).toBe(2); + expect(states[1]).toBe('value'); + promiseStore$.set(Promise.reject('ignored-rejection')); + expect(states.length).toBe(2); + await 0; + expect(states.length).toBe(2); + promiseStore$.set(Promise.resolve('other')); + expect(states.length).toBe(2); // the new value cannot be known synchronously + await 0; + expect(states.length).toBe(3); + expect(states[2]).toBe('other'); + promiseStore$.set(firstPromise); // the old value is already known + expect(states.length).toBe(4); + expect(states[3]).toBe('value'); + }); +}); diff --git a/core/lib/services/promiseStoreUtils.ts b/core/lib/services/promiseStoreUtils.ts new file mode 100644 index 0000000000..9324d5cde0 --- /dev/null +++ b/core/lib/services/promiseStoreUtils.ts @@ -0,0 +1,74 @@ +import type {ReadableSignal} from '@amadeus-it-group/tansu'; +import {asReadable, computed, derived, readable, writable} from '@amadeus-it-group/tansu'; + +export interface PromisePendingResult { + /** Pending status */ + status: 'pending'; +} +export const promisePending: PromisePendingResult = {status: 'pending'}; + +export type PromiseState = PromiseFulfilledResult | PromiseRejectedResult | PromisePendingResult; + +const isThenable = (value: any): value is PromiseLike => { + // cf https://tc39.es/ecma262/#sec-promise-resolve-functions + const type = typeof value; + return (type === 'object' && value !== null) || type === 'function' ? typeof value.then === 'function' : false; +}; + +const createPromiseStateStore = (promise: PromiseLike): ReadableSignal> => { + const store = writable(promisePending as PromiseState); + Promise.resolve(promise).then( + (value) => store.set({status: 'fulfilled', value}), + (reason) => store.set({status: 'rejected', reason}), + ); + return asReadable(store); +}; + +const promiseWeakMap = new WeakMap, ReadableSignal>>(); +export const promiseStateStore = (value: T): ReadableSignal>>> => { + if (!isThenable(value)) { + return readable({status: 'fulfilled', value: value as Awaited}); + } + let response = promiseWeakMap.get(value); + if (!response) { + response = createPromiseStateStore(value); + promiseWeakMap.set(value, response); + } + return response; +}; + +// same equal as in tansu: +const equal = (a: T, b: T): boolean => Object.is(a, b) && (!a || typeof a !== 'object') && typeof a !== 'function'; + +const promiseStateStoreEqual = (a: PromiseState, b: PromiseState) => + Object.is(a, b) || + (a.status === b.status && + (a.status !== 'fulfilled' || equal(a.value, (b as PromiseFulfilledResult).value)) && + (a.status !== 'rejected' || equal(a.reason, (b as PromiseRejectedResult).reason))); + +export const promiseStoreToPromiseStateStore = (promiseStore$: ReadableSignal): ReadableSignal>> => + computed(() => promiseStateStore(promiseStore$())(), {equal: promiseStateStoreEqual}); + +export const promiseStateStoreToValueStore = ( + store$: ReadableSignal>, + initialValue: T, + equal?: (a: T, b: T) => boolean, +): ReadableSignal => + derived( + store$, + { + derive: (state, set) => { + if (state.status === 'fulfilled') { + set(state.value); + } + }, + equal, + }, + initialValue, + ); + +export const promiseStoreToValueStore = ( + promiseStore$: ReadableSignal, + initialValue: Awaited, + equal?: (a: Awaited, b: Awaited) => boolean, +): ReadableSignal> => promiseStateStoreToValueStore(promiseStoreToPromiseStateStore(promiseStore$), initialValue, equal); diff --git a/core/package.json b/core/package.json index 0ad78f5648..e75a47c454 100644 --- a/core/package.json +++ b/core/package.json @@ -12,7 +12,8 @@ "test:coverage": "node -r @agnos-ui/code-coverage/interceptReadFile ../node_modules/vitest/vitest.mjs run --coverage" }, "dependencies": { - "@amadeus-it-group/tansu": "0.0.23" + "@amadeus-it-group/tansu": "0.0.23", + "@floating-ui/dom": "^1.5.3" }, "devDependencies": { "eslint-plugin-jsdoc": "^46.9.0" diff --git a/e2e/demo-po/floatingUI.po.ts b/e2e/demo-po/floatingUI.po.ts new file mode 100644 index 0000000000..68e3af475b --- /dev/null +++ b/e2e/demo-po/floatingUI.po.ts @@ -0,0 +1,28 @@ +import {BasePO} from '@agnos-ui/base-po'; + +export class FloatingUIDemoPO extends BasePO { + getComponentSelector(): string { + return 'div.demo-floatingui'; + } + + get locatorTogglePopoverButton() { + return this.locatorRoot.locator('button'); + } + + get locatorPopover() { + return this.locatorRoot.locator('.popover'); + } + + get locatorPopoverArrow() { + return this.locatorPopover.locator('.popover-arrow'); + } + + async setScrollPosition(left: number, top: number) { + await this.locatorRoot.evaluate( + (domElement, position) => { + domElement.scrollTo(position); + }, + {left, top}, + ); + } +} diff --git a/e2e/floatingUI/floatingUI.e2e-spec.ts b/e2e/floatingUI/floatingUI.e2e-spec.ts new file mode 100644 index 0000000000..f8e6d0d15b --- /dev/null +++ b/e2e/floatingUI/floatingUI.e2e-spec.ts @@ -0,0 +1,32 @@ +import {expect, getTest} from '../fixture'; +import {FloatingUIDemoPO} from '../demo-po/floatingUI.po'; + +const test = getTest(); +test.describe(`FloatingUI tests`, () => { + test.beforeEach(async ({page}) => { + await page.goto('#/floatingui/floatingui'); + }); + + test('Basic test', async ({page}) => { + const demoPO = new FloatingUIDemoPO(page); + expect(await demoPO.locatorPopover.boundingBox()).toEqual({x: 211, y: 32, width: 250, height: 55}); + expect(await demoPO.locatorPopoverArrow.boundingBox()).toEqual({x: 328, y: 87, width: 16, height: 8}); + await demoPO.locatorTogglePopoverButton.click(); + expect(demoPO.locatorPopover).not.toBeVisible(); + await demoPO.locatorTogglePopoverButton.click(); + expect(await demoPO.locatorPopover.boundingBox()).toEqual({x: 211, y: 32, width: 250, height: 55}); + expect(await demoPO.locatorPopoverArrow.boundingBox()).toEqual({x: 328, y: 87, width: 16, height: 8}); + await demoPO.setScrollPosition(326, 538); + await expect(demoPO.locatorPopover).not.toBeVisible(); // button hidden at the top + await demoPO.setScrollPosition(326, 537); // just one pixel of the button is visible at the top + await expect(demoPO.locatorPopover).toBeVisible(); + expect(await demoPO.locatorPopover.boundingBox()).toEqual({x: 211, y: 28, width: 250, height: 55}); + expect(await demoPO.locatorPopoverArrow.boundingBox()).toEqual({x: 328, y: 20, width: 16, height: 8}); + await demoPO.setScrollPosition(650, 420); + await expect(demoPO.locatorPopover).not.toBeVisible(); // button hidden on the left + await demoPO.setScrollPosition(649, 420); // just one pixel of the button is visible on the left + await expect(demoPO.locatorPopover).toBeVisible(); + expect(await demoPO.locatorPopover.boundingBox()).toEqual({x: 98, y: 88.5, width: 250, height: 55}); + expect(await demoPO.locatorPopoverArrow.boundingBox()).toEqual({x: 90, y: 108, width: 8, height: 16}); + }); +}); diff --git a/e2e/samplesMarkup.e2e-spec.ts-snapshots/floatingui-floatingui.html b/e2e/samplesMarkup.e2e-spec.ts-snapshots/floatingui-floatingui.html new file mode 100644 index 0000000000..f3ec3b20f3 --- /dev/null +++ b/e2e/samplesMarkup.e2e-spec.ts-snapshots/floatingui-floatingui.html @@ -0,0 +1,36 @@ + +
+
+
+ + +
+
+ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0bdcd26499..19edec3345 100644 --- a/package-lock.json +++ b/package-lock.json @@ -107,7 +107,8 @@ "core": { "name": "@agnos-ui/core", "dependencies": { - "@amadeus-it-group/tansu": "0.0.23" + "@amadeus-it-group/tansu": "0.0.23", + "@floating-ui/dom": "^1.5.3" }, "devDependencies": { "eslint-plugin-jsdoc": "^46.9.0" @@ -3744,6 +3745,28 @@ "node": ">=14" } }, + "node_modules/@floating-ui/core": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.0.tgz", + "integrity": "sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==", + "dependencies": { + "@floating-ui/utils": "^0.1.3" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz", + "integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==", + "dependencies": { + "@floating-ui/core": "^1.4.2", + "@floating-ui/utils": "^0.1.3" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", + "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", diff --git a/react/demo/app/samples/floatingUI/FloatingUI.route.tsx b/react/demo/app/samples/floatingUI/FloatingUI.route.tsx new file mode 100644 index 0000000000..73bb7473cb --- /dev/null +++ b/react/demo/app/samples/floatingUI/FloatingUI.route.tsx @@ -0,0 +1,57 @@ +import '@agnos-ui/common/samples/floatingui/floatingui.scss'; +import {createFloatingUI, floatingUI, useDirective, useObservable} from '@agnos-ui/react'; +import {useMemo, useState} from 'react'; + +const scrollToMiddle = (element: HTMLElement) => { + element.scrollTo({left: 326, top: 420}); +}; + +const FloatingUI = () => { + const [displayPopover, setDisplayPopover] = useState(true); + const floatingUIInstance = useMemo( + () => + createFloatingUI({ + props: { + arrowOptions: { + padding: 6, + }, + computePositionOptions: { + middleware: [ + floatingUI.offset(10), + floatingUI.autoPlacement(), + floatingUI.shift({ + padding: 5, + }), + floatingUI.hide(), + ], + }, + }, + }), + [], + ); + const floatingUIState = useObservable(floatingUIInstance.state$); + const refContainer = useDirective(scrollToMiddle); + const refReference = useDirective(floatingUIInstance.directives.referenceDirective); + const refFloating = useDirective(floatingUIInstance.directives.floatingDirective); + const refArrow = useDirective(floatingUIInstance.directives.arrowDirective); + + return ( +
+ + {displayPopover ? ( +
+
+
This is a sample popover
+
+ ) : null} +
+ ); +}; +export default FloatingUI; diff --git a/svelte/demo/samples/floatingUI/FloatingUI.route.svelte b/svelte/demo/samples/floatingUI/FloatingUI.route.svelte new file mode 100644 index 0000000000..41a2f52914 --- /dev/null +++ b/svelte/demo/samples/floatingUI/FloatingUI.route.svelte @@ -0,0 +1,50 @@ + + +
+ + {#if displayPopover} + + {/if} +