Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- -- <vitest args>` from the root of the project
Expand All @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
111 changes: 111 additions & 0 deletions packages/tests/Hoverable/HoverableController.spec.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
9 changes: 8 additions & 1 deletion packages/ui/Hoverable/Hoverable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,14 @@ export class Hoverable<T extends BaseProps = BaseProps> 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;
Expand Down
66 changes: 66 additions & 0 deletions packages/ui/Hoverable/HoverableController.ts
Original file line number Diff line number Diff line change
@@ -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
* <div data-component="HoverableController" data-option-controls="my-hoverable"></div>
*
* <div data-component="Hoverable" id="my-hoverable">
* <div data-ref="target">...</div>
* </div>
* ```
*
* @see https://ui.studiometa.dev/-/components/Hoverable/
*/
export class HoverableController<T extends BaseProps = BaseProps> 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);
}
}
}
1 change: 1 addition & 0 deletions packages/ui/Hoverable/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './Hoverable.js';
export * from './HoverableController.js';
Loading