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) {
+
+
+
This is a sample popover
+
+ }
+
`,
+
+ 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 @@
+
+
+
+
+
+
+
+
+ "This is a sample popover"
+
+
+
+
+
+
\ 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}
+
+
+
This is a sample popover
+
+ {/if}
+