diff --git a/CLAUDE.md b/CLAUDE.md index c0790597..4d03eb20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,10 +3,16 @@ ## 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` +## 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 @@ -19,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. 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'; 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';