From b8fc0eefb0f315e1de172ff7ab8a05ea4ef5158e Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 4 Sep 2025 21:58:38 +0200 Subject: [PATCH 1/5] Update Claude instructions --- CLAUDE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index c0790597..a780fcf7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,12 @@ - The main NPM package is `@studiometa/ui` and lives in `./packages/ui` - A Composer package providing a Twig extension can be found in `./packages/twig-extension` +## Development + +- Start the project locally with `ddev start`, the URL is `https://ui.ddev.site` +- Start the docs development server with `npm run docs:dev` +- Build the docs with `npm run docs:build` + ## Tests - Tests for the TypeScript components exported by the `@studiometa/ui` packages are managed by Vitest and are located in the `./packages/tests` folder, they can be run with the `npm run test` command or `npm run test -- -- ` from the root of the project From 9174986b4657213be6bfa7e551b2253b8a9483eb Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Fri, 5 Sep 2025 08:02:26 +0200 Subject: [PATCH 2/5] Update Claude instructions --- CLAUDE.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index a780fcf7..4d03eb20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ ## Project structure - Monorepo managed by NPM with packages in the `./packages` folder -- NPM packages in this repository are ESM only +- NPM packages in this repository are ESM only, CJS should never be used - The main NPM package is `@studiometa/ui` and lives in `./packages/ui` - A Composer package providing a Twig extension can be found in `./packages/twig-extension` @@ -25,3 +25,7 @@ - Use `npm run lint` for TypeScript code quality - Use `composer lint` to check for PHP Code Quality - Use `composer fix` to fix fixable code quality errors reported by the `composer lint` command + +## Playground + +If you see a link to `https://ui.studiometa.dev/-/play/`, it is a link to the playground for all components. You can either visit the page to see how it renders, or directly extract the content from the URL query string stored in the URL hash. The content is encoded and zipped, you can unzip it with the `unzip` export from the `@studiometa/playground` NPM package. From d5b26bb89961ac08cf41f94451e2ff6e703803e3 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Fri, 5 Sep 2025 08:02:32 +0200 Subject: [PATCH 3/5] Update an import --- packages/docs/.vitepress/theme/components/PreviewPlayground.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/.vitepress/theme/components/PreviewPlayground.vue b/packages/docs/.vitepress/theme/components/PreviewPlayground.vue index 709a4790..a1bc82f2 100644 --- a/packages/docs/.vitepress/theme/components/PreviewPlayground.vue +++ b/packages/docs/.vitepress/theme/components/PreviewPlayground.vue @@ -4,7 +4,7 @@ import { UseElementVisibility } from '@vueuse/components'; import { useData } from 'vitepress'; import { isFunction, isString } from '@studiometa/js-toolkit/utils'; - import { zip } from '@studiometa/playground/dist/lib/utils/zip.js'; + import { zip } from '@studiometa/playground'; import Loader from './Loader.vue'; import ControlButton from './PreviewControlButton.vue'; From e8ee414f91157fb0ce8426ed0a1ed4165b21bbd6 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Fri, 5 Sep 2025 08:16:38 +0200 Subject: [PATCH 4/5] Add a HoverableController component Fix: #446 --- packages/ui/Hoverable/Hoverable.ts | 9 ++- packages/ui/Hoverable/HoverableController.ts | 66 ++++++++++++++++++++ packages/ui/Hoverable/index.ts | 1 + 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 packages/ui/Hoverable/HoverableController.ts 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'; From 8b1bf224b61456e5db5f547e72fbf9ff6d6a46a3 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Fri, 5 Sep 2025 08:16:44 +0200 Subject: [PATCH 5/5] Add tests --- .../Hoverable/HoverableController.spec.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 packages/tests/Hoverable/HoverableController.spec.ts 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