diff --git a/packages/tests/Hoverable/HoverableController.spec.ts b/packages/tests/Hoverable/HoverableController.spec.ts new file mode 100644 index 00000000..2a6044be --- /dev/null +++ b/packages/tests/Hoverable/HoverableController.spec.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { PointerServiceProps } from '@studiometa/js-toolkit'; +import { Hoverable, HoverableController } from '@studiometa/ui'; +import { h, mount } from '#test-utils'; + +function pointerProgress(x: number, y: number) { + return { + progress: { x, y }, + } as PointerServiceProps; +} + +describe('The HoverableController component', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + it('should find and control a Hoverable component by id', async () => { + // Create the controlled Hoverable component manually + const target = h('div', { dataRef: 'target' }); + const hoverableDiv = h('div', { id: 'controlled-hoverable' }, [target]); + document.body.appendChild(hoverableDiv); + + const hoverable = new Hoverable(hoverableDiv); + await mount(hoverable); + + // Mock the bounds for testing + const spy = vi.spyOn(hoverable, 'bounds', 'get'); + spy.mockImplementation(() => ({ + xMin: 0, + xMax: 100, + yMin: 0, + yMax: 100, + })); + + // Create the controller + const controllerDiv = h('div', { dataOptionControls: 'controlled-hoverable' }); + const controller = new HoverableController(controllerDiv); + await mount(controller); + + // Mock the getInstanceFromElement call in the controller + const controllerSpy = vi.spyOn(controller, 'hoverable', 'get'); + controllerSpy.mockImplementation(() => hoverable); + + // Test that controller can find the hoverable + expect(controller.hoverable).toBe(hoverable); + + // Test that controller can control the hoverable + const hoverableSpy = vi.spyOn(hoverable, 'movedrelative'); + + controller.movedrelative(pointerProgress(0.5, 0.5)); + + expect(hoverableSpy).toHaveBeenCalledWith(pointerProgress(0.5, 0.5), true); + expect(hoverable.props.x).toBe(50); + expect(hoverable.props.y).toBe(50); + }); + + it('should return null when controlled element is not found', async () => { + const controllerDiv = h('div', { dataOptionControls: 'non-existent' }); + const controller = new HoverableController(controllerDiv); + await mount(controller); + + expect(controller.hoverable).toBe(null); + }); + + it('should return null when no controls option is provided', async () => { + const controllerDiv = h('div'); + const controller = new HoverableController(controllerDiv); + await mount(controller); + + expect(controller.hoverable).toBe(null); + }); + + it('should not crash when controlling a non-existent hoverable', async () => { + const controllerDiv = h('div', { dataOptionControls: 'non-existent' }); + const controller = new HoverableController(controllerDiv); + await mount(controller); + + expect(() => { + controller.movedrelative(pointerProgress(0.5, 0.5)); + }).not.toThrow(); + }); + + it('should allow a Hoverable to work normally when not controlled', async () => { + const target = h('div', { dataRef: 'target' }); + const div = h('div', [target]); + const hoverable = new Hoverable(div); + const spy = vi.spyOn(hoverable, 'bounds', 'get'); + spy.mockImplementation(() => ({ + xMin: 0, + xMax: 100, + yMin: 0, + yMax: 100, + })); + await mount(hoverable); + + // Normal behavior should still work + hoverable.movedrelative(pointerProgress(0.5, 0.5)); + expect(hoverable.props.x).toBe(50); + expect(hoverable.props.y).toBe(50); + + // Controlled behavior should work + hoverable.movedrelative(pointerProgress(0.8, 0.8), true); + expect(hoverable.props.x).toBe(80); + expect(hoverable.props.y).toBe(80); + + // Disabled behavior should not work + hoverable.movedrelative(pointerProgress(0.2, 0.2), false); + expect(hoverable.props.x).toBe(80); // Should remain unchanged + expect(hoverable.props.y).toBe(80); // Should remain unchanged + }); +}); \ No newline at end of file diff --git a/packages/ui/Hoverable/Hoverable.ts b/packages/ui/Hoverable/Hoverable.ts index 5ee28b9b..39794540 100644 --- a/packages/ui/Hoverable/Hoverable.ts +++ b/packages/ui/Hoverable/Hoverable.ts @@ -91,7 +91,14 @@ export class Hoverable extends withRelativePoin /** * Update props when the mouse moves. */ - movedrelative({ progress }: PointerServiceProps) { + movedrelative({ progress }: PointerServiceProps, isControlled?: boolean) { + // When controlled externally, allow the update + // When not controlled, proceed with normal behavior (isControlled is undefined) + // When controlled is false, it means we want to prevent default behavior + if (isControlled === false) { + return; + } + const { bounds, props } = this; const { reversed, contained } = this.$options; const { x, y } = progress; diff --git a/packages/ui/Hoverable/HoverableController.ts b/packages/ui/Hoverable/HoverableController.ts new file mode 100644 index 00000000..848494e0 --- /dev/null +++ b/packages/ui/Hoverable/HoverableController.ts @@ -0,0 +1,66 @@ +import { Base, withRelativePointer, getInstanceFromElement } from '@studiometa/js-toolkit'; +import type { BaseConfig, BaseProps, PointerServiceProps } from '@studiometa/js-toolkit'; +import { Hoverable } from './Hoverable.js'; +import { isFunction } from '@studiometa/js-toolkit/utils'; + +export interface HoverableControllerProps extends BaseProps { + $options: { + /** + * A selector for the Hoverable component to control. + */ + controls: string; + }; +} + +/** + * Controller for the Hoverable component. + * + * Allows controlling a Hoverable component from a separate element. + * The controller captures pointer movements and forwards them to a controlled + * Hoverable component specified by the `controls` option. + * + * @example + * ```html + *
+ * + *
+ *
...
+ *
+ * ``` + * + * @see https://ui.studiometa.dev/-/components/Hoverable/ + */ +export class HoverableController extends withRelativePointer(Base)< + T & HoverableControllerProps +> { + /** + * Config. + */ + static config: BaseConfig = { + name: 'HoverableController', + options: { + controls: String, + }, + }; + + /** + * Get the controlled Hoverable instance. + */ + get hoverable(): Hoverable | null { + const { controls } = this.$options; + return controls + ? getInstanceFromElement(document.querySelector(`#${controls}`), Hoverable) + : null; + } + + /** + * Dispatch the progress from the controller to the controlled + * Hoverable component. + */ + movedrelative(props: PointerServiceProps) { + const { hoverable } = this; + if (hoverable && isFunction(hoverable.movedrelative)) { + hoverable.movedrelative(props, true); + } + } +} diff --git a/packages/ui/Hoverable/index.ts b/packages/ui/Hoverable/index.ts index 51445fc7..f5a816f5 100644 --- a/packages/ui/Hoverable/index.ts +++ b/packages/ui/Hoverable/index.ts @@ -1 +1,2 @@ export * from './Hoverable.js'; +export * from './HoverableController.js';