diff --git a/.oxlintrc.json b/.oxlintrc.json index 7678e9d0..da72c4e4 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,6 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["vitest", "import", "promise", "regex", "jsdoc"], + "plugins": ["vitest", "import", "promise", "jsdoc"], "rules": { "func-style": ["warn", "declaration"], "no-floating-promises": "allow", diff --git a/packages/docs/components/Hoverable/examples.md b/packages/docs/components/Hoverable/examples.md index 03a41479..9b67ece2 100644 --- a/packages/docs/components/Hoverable/examples.md +++ b/packages/docs/components/Hoverable/examples.md @@ -6,11 +6,11 @@ title: Hoverable examples ## Default -In this example, we showcase the default behaviour as well as [the `reversed`](./js-api.md#reversed) and [`contained` options](./js-api.md#contained). +Moves the target relative to the pointer inside rectangular bounds. @@ -18,7 +18,112 @@ In this example, we showcase the default behaviour as well as [the `reversed`](. :::code-group -<<< ./stories/app.twig +<<< ./stories/default.twig +<<< ./stories/app.js + +::: + + + +## Reversed + +Inverts the movement direction for a counter-motion effect. + + + + + + +:::code-group + +<<< ./stories/reversed.twig +<<< ./stories/app.js + +::: + + + +## Contained + +Stops updating when the pointer leaves the root element. + + + + + + +:::code-group + +<<< ./stories/contained.twig +<<< ./stories/app.js + +::: + + + +## Reversed and contained + +Combines reversed motion with contained pointer tracking. + + + + + + +:::code-group + +<<< ./stories/reversed-contained.twig +<<< ./stories/app.js + +::: + + + +## Circle shape + +Constrains movement to an inscribed circular area. + + + + + + +:::code-group + +<<< ./stories/circle.twig +<<< ./stories/app.js + +::: + + + +## Ellipse shape + +Constrains movement to an inscribed elliptical area. + + + + + + +:::code-group + +<<< ./stories/ellipse.twig <<< ./stories/app.js ::: diff --git a/packages/docs/components/Hoverable/js-api.md b/packages/docs/components/Hoverable/js-api.md index 37de60d8..b515ec00 100644 --- a/packages/docs/components/Hoverable/js-api.md +++ b/packages/docs/components/Hoverable/js-api.md @@ -11,7 +11,7 @@ The `Hoverable` component uses the [`withRelativePointer` decorator](https://js- ### `sensitivity` - Type: `number` -- Default: `0.5` +- Default: `0.1` A number between in the range `0–1` used to smoothen the transition between each position. @@ -29,6 +29,13 @@ Use this option to reverse the movement of the target. Use this option to stop moving the target element when the pointer has leaved the root element. +### `shape` + +- Type: `'rect' | 'circle' | 'ellipse'` +- Default: `'rect'` + +Use this option to constrain the target movement to an inscribed shape instead of the default rectangular bounds. + ## Properties ### `props` @@ -37,6 +44,16 @@ Use this option to stop moving the target element when the pointer has leaved th The values used to calculate and render the position of the target element. +## Methods + +### `constrainPosition` + +- Signature: `(x: number, y: number, bounds = this.bounds) => { x: number, y: number }` + +Constrains the given position to the configured bounding shape. + +By default, this method supports the built-in `rect`, `circle` and `ellipse` shapes. You can override it in a custom component to implement more advanced constraints. + ## Getters ### `target` @@ -57,3 +74,5 @@ The element used to calculate the bound limits. - Return: `{ yMin: number, yMax: number, xMin: number, xMax: number }` - Default: the minimum and maximum position for the `target` element to stay in the `parent` bounds + +The `shape` option uses these bounds as its base rectangle and constrains the target to either that rectangle, an inscribed circle, or an inscribed ellipse. diff --git a/packages/docs/components/Hoverable/stories/app.twig b/packages/docs/components/Hoverable/stories/app.twig index ce252f67..f83722df 100644 --- a/packages/docs/components/Hoverable/stories/app.twig +++ b/packages/docs/components/Hoverable/stories/app.twig @@ -1,11 +1,16 @@
-
+
-

Default

-
+ Default +

+
-
{% include '@ui/Figure/Figure.twig' with { src: 'https://picsum.photos/600/600', @@ -28,12 +33,16 @@
-

Reversed

-
+ Reversed +

+
-
{% include '@ui/Figure/Figure.twig' with { src: 'https://picsum.photos/600/600', @@ -55,14 +64,19 @@
-
+
-

Contained

-
+ Contained +

+
-
{% include '@ui/Figure/Figure.twig' with { src: 'https://picsum.photos/600/600', @@ -84,13 +98,17 @@
-

Reversed & Contained

-
+ Reversed & Contained +

+
-
{% include '@ui/Figure/Figure.twig' with { src: 'https://picsum.photos/600/600', @@ -112,4 +130,69 @@
+
+
+

+ Circle +

+
+
+
+ {% include '@ui/Figure/Figure.twig' with { + src: 'https://picsum.photos/600/600', + width: 600, + height: 600, + fit: 'cover', + absolute: true, + attr: { + data_option_enter_from: 'opacity-0' + }, + inner_attr: { + class: 'bg-vp-bg rounded-full overflow-hidden' + }, + img_attr: { + class: 'opacity-0 transform' + } + } only %} +
+
+
+
+

+ Ellipse +

+
+
+
+ {% include '@ui/Figure/Figure.twig' with { + src: 'https://picsum.photos/600/600', + width: 600, + height: 600, + fit: 'cover', + absolute: true, + attr: { + data_option_enter_from: 'opacity-0' + }, + inner_attr: { + class: 'bg-vp-bg rounded-[9999px] overflow-hidden' + }, + img_attr: { + class: 'opacity-0 transform' + } + } only %} +
+
+
+
diff --git a/packages/docs/components/Hoverable/stories/circle.twig b/packages/docs/components/Hoverable/stories/circle.twig new file mode 100644 index 00000000..9f4eb14e --- /dev/null +++ b/packages/docs/components/Hoverable/stories/circle.twig @@ -0,0 +1,67 @@ +
+
+
+

+ Oversized target +

+
+
+
+ {% include '@ui/Figure/Figure.twig' with { + src: 'https://picsum.photos/600/600', + width: 600, + height: 600, + fit: 'cover', + absolute: true, + attr: { + data_option_enter_from: 'opacity-0' + }, + inner_attr: { + class: 'bg-vp-bg rounded-full overflow-hidden' + }, + img_attr: { + class: 'opacity-0 transform' + } + } only %} +
+
+
+ +
+

+ Undersized target +

+
+
+
+ {% include '@ui/Figure/Figure.twig' with { + src: 'https://picsum.photos/600/600', + width: 600, + height: 600, + fit: 'cover', + absolute: true, + attr: { + data_option_enter_from: 'opacity-0' + }, + inner_attr: { + class: 'bg-vp-bg rounded-full overflow-hidden' + }, + img_attr: { + class: 'opacity-0 transform' + } + } only %} +
+
+
+
+
diff --git a/packages/docs/components/Hoverable/stories/contained.twig b/packages/docs/components/Hoverable/stories/contained.twig new file mode 100644 index 00000000..cc24e3c6 --- /dev/null +++ b/packages/docs/components/Hoverable/stories/contained.twig @@ -0,0 +1,67 @@ +
+
+
+

+ Oversized target +

+
+
+
+ {% include '@ui/Figure/Figure.twig' with { + src: 'https://picsum.photos/600/600', + width: 600, + height: 600, + fit: 'cover', + absolute: true, + attr: { + data_option_enter_from: 'opacity-0' + }, + inner_attr: { + class: 'bg-vp-bg' + }, + img_attr: { + class: 'opacity-0 transform' + } + } only %} +
+
+
+ +
+

+ Undersized target +

+
+
+
+ {% include '@ui/Figure/Figure.twig' with { + src: 'https://picsum.photos/600/600', + width: 600, + height: 600, + fit: 'cover', + absolute: true, + attr: { + data_option_enter_from: 'opacity-0' + }, + inner_attr: { + class: 'bg-vp-bg' + }, + img_attr: { + class: 'opacity-0 transform' + } + } only %} +
+
+
+
+
diff --git a/packages/docs/components/Hoverable/stories/default.twig b/packages/docs/components/Hoverable/stories/default.twig new file mode 100644 index 00000000..e2d0df88 --- /dev/null +++ b/packages/docs/components/Hoverable/stories/default.twig @@ -0,0 +1,65 @@ +
+
+
+

+ Oversized target +

+
+
+
+ {% include '@ui/Figure/Figure.twig' with { + src: 'https://picsum.photos/600/600', + width: 600, + height: 600, + fit: 'cover', + absolute: true, + attr: { + data_option_enter_from: 'opacity-0' + }, + inner_attr: { + class: 'bg-vp-bg' + }, + img_attr: { + class: 'opacity-0 transform' + } + } only %} +
+
+
+ +
+

+ Undersized target +

+
+
+
+ {% include '@ui/Figure/Figure.twig' with { + src: 'https://picsum.photos/600/600', + width: 600, + height: 600, + fit: 'cover', + absolute: true, + attr: { + data_option_enter_from: 'opacity-0' + }, + inner_attr: { + class: 'bg-vp-bg' + }, + img_attr: { + class: 'opacity-0 transform' + } + } only %} +
+
+
+
+
diff --git a/packages/docs/components/Hoverable/stories/ellipse.twig b/packages/docs/components/Hoverable/stories/ellipse.twig new file mode 100644 index 00000000..56f98f57 --- /dev/null +++ b/packages/docs/components/Hoverable/stories/ellipse.twig @@ -0,0 +1,67 @@ +
+
+
+

+ Oversized target +

+
+
+
+ {% include '@ui/Figure/Figure.twig' with { + src: 'https://picsum.photos/600/600', + width: 600, + height: 600, + fit: 'cover', + absolute: true, + attr: { + data_option_enter_from: 'opacity-0' + }, + inner_attr: { + class: 'bg-vp-bg rounded-[9999px] overflow-hidden' + }, + img_attr: { + class: 'opacity-0 transform' + } + } only %} +
+
+
+ +
+

+ Undersized target +

+
+
+
+ {% include '@ui/Figure/Figure.twig' with { + src: 'https://picsum.photos/600/600', + width: 600, + height: 600, + fit: 'cover', + absolute: true, + attr: { + data_option_enter_from: 'opacity-0' + }, + inner_attr: { + class: 'bg-vp-bg rounded-[9999px] overflow-hidden' + }, + img_attr: { + class: 'opacity-0 transform' + } + } only %} +
+
+
+
+
diff --git a/packages/docs/components/Hoverable/stories/reversed-contained.twig b/packages/docs/components/Hoverable/stories/reversed-contained.twig new file mode 100644 index 00000000..c89a393d --- /dev/null +++ b/packages/docs/components/Hoverable/stories/reversed-contained.twig @@ -0,0 +1,69 @@ +
+
+
+

+ Oversized target +

+
+
+
+ {% include '@ui/Figure/Figure.twig' with { + src: 'https://picsum.photos/600/600', + width: 600, + height: 600, + fit: 'cover', + absolute: true, + attr: { + data_option_enter_from: 'opacity-0' + }, + inner_attr: { + class: 'bg-vp-bg' + }, + img_attr: { + class: 'opacity-0 transform' + } + } only %} +
+
+
+ +
+

+ Undersized target +

+
+
+
+ {% include '@ui/Figure/Figure.twig' with { + src: 'https://picsum.photos/600/600', + width: 600, + height: 600, + fit: 'cover', + absolute: true, + attr: { + data_option_enter_from: 'opacity-0' + }, + inner_attr: { + class: 'bg-vp-bg' + }, + img_attr: { + class: 'opacity-0 transform' + } + } only %} +
+
+
+
+
diff --git a/packages/docs/components/Hoverable/stories/reversed.twig b/packages/docs/components/Hoverable/stories/reversed.twig new file mode 100644 index 00000000..b38edb97 --- /dev/null +++ b/packages/docs/components/Hoverable/stories/reversed.twig @@ -0,0 +1,67 @@ +
+
+
+

+ Oversized target +

+
+
+
+ {% include '@ui/Figure/Figure.twig' with { + src: 'https://picsum.photos/600/600', + width: 600, + height: 600, + fit: 'cover', + absolute: true, + attr: { + data_option_enter_from: 'opacity-0' + }, + inner_attr: { + class: 'bg-vp-bg' + }, + img_attr: { + class: 'opacity-0 transform' + } + } only %} +
+
+
+ +
+

+ Undersized target +

+
+
+
+ {% include '@ui/Figure/Figure.twig' with { + src: 'https://picsum.photos/600/600', + width: 600, + height: 600, + fit: 'cover', + absolute: true, + attr: { + data_option_enter_from: 'opacity-0' + }, + inner_attr: { + class: 'bg-vp-bg' + }, + img_attr: { + class: 'opacity-0 transform' + } + } only %} +
+
+
+
+
diff --git a/packages/tests/Hoverable/Hoverable.spec.ts b/packages/tests/Hoverable/Hoverable.spec.ts index 1ffa509d..1b9f349b 100644 --- a/packages/tests/Hoverable/Hoverable.spec.ts +++ b/packages/tests/Hoverable/Hoverable.spec.ts @@ -73,6 +73,94 @@ describe('The Hoverable component', () => { expect(hoverable.props.y).toBe(0); }); + it('should constrain the target position to a circle when the circle shape is used', async () => { + const target = h('div', { dataRef: 'target' }); + const div = h('div', { dataOptionShape: 'circle' }, [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); + + hoverable.movedrelative(pointerProgress(0, 0)); + expect(hoverable.props.x).toBeCloseTo(14.64466094067263); + expect(hoverable.props.y).toBeCloseTo(14.64466094067263); + + hoverable.movedrelative(pointerProgress(0.5, 0.5)); + expect(hoverable.props.x).toBe(50); + expect(hoverable.props.y).toBe(50); + }); + + it('should constrain the target position to a circle when bounds are inverted', async () => { + const target = h('div', { dataRef: 'target' }); + const div = h('div', { dataOptionShape: 'circle' }, [target]); + const hoverable = new Hoverable(div); + const spy = vi.spyOn(hoverable, 'bounds', 'get'); + spy.mockImplementation(() => ({ + xMin: 20, + xMax: -20, + yMin: 20, + yMax: -20, + })); + await mount(hoverable); + + hoverable.movedrelative(pointerProgress(0, 0)); + expect(hoverable.props.x).toBeCloseTo(14.14213562373095); + expect(hoverable.props.y).toBeCloseTo(14.14213562373095); + + hoverable.movedrelative(pointerProgress(0.5, 0.5)); + expect(hoverable.props.x).toBe(0); + expect(hoverable.props.y).toBe(0); + }); + + it('should constrain the target position to an ellipse when the ellipse shape is used', async () => { + const target = h('div', { dataRef: 'target' }); + const div = h('div', { dataOptionShape: 'ellipse' }, [target]); + const hoverable = new Hoverable(div); + const spy = vi.spyOn(hoverable, 'bounds', 'get'); + spy.mockImplementation(() => ({ + xMin: 0, + xMax: 200, + yMin: 0, + yMax: 100, + })); + await mount(hoverable); + + hoverable.movedrelative(pointerProgress(0, 0)); + expect(hoverable.props.x).toBeCloseTo(29.28932188134526); + expect(hoverable.props.y).toBeCloseTo(14.64466094067263); + + hoverable.movedrelative(pointerProgress(0.5, 0.5)); + expect(hoverable.props.x).toBe(100); + expect(hoverable.props.y).toBe(50); + }); + + it('should constrain the target position to an ellipse when bounds are inverted', async () => { + const target = h('div', { dataRef: 'target' }); + const div = h('div', { dataOptionShape: 'ellipse' }, [target]); + const hoverable = new Hoverable(div); + const spy = vi.spyOn(hoverable, 'bounds', 'get'); + spy.mockImplementation(() => ({ + xMin: 40, + xMax: -40, + yMin: 20, + yMax: -20, + })); + await mount(hoverable); + + hoverable.movedrelative(pointerProgress(0, 0)); + expect(hoverable.props.x).toBeCloseTo(28.2842712474619); + expect(hoverable.props.y).toBeCloseTo(14.14213562373095); + + hoverable.movedrelative(pointerProgress(0.5, 0.5)); + expect(hoverable.props.x).toBe(0); + expect(hoverable.props.y).toBe(0); + }); + it('should stop update x and y position when contained option is used and mouse position is out of bounds', async () => { const target = h('div', { dataRef: 'target' }); const div = h('div', { dataOptionContained: true }, [target]); diff --git a/packages/ui/Hoverable/Hoverable.ts b/packages/ui/Hoverable/Hoverable.ts index 4ba6e6ea..a69a0244 100644 --- a/packages/ui/Hoverable/Hoverable.ts +++ b/packages/ui/Hoverable/Hoverable.ts @@ -22,6 +22,10 @@ export interface HoverableProps extends BaseProps { * Wether to stop moving the target when the mouse is not over the root element or not. */ contained: boolean; + /** + * The bounding shape used to constrain the target movement. + */ + shape: 'rect' | 'circle' | 'ellipse'; }; } @@ -45,6 +49,10 @@ export class Hoverable extends withRelativePoin }, reversed: Boolean, contained: Boolean, + shape: { + type: String, + default: 'rect', + }, }, }; @@ -88,6 +96,50 @@ export class Hoverable extends withRelativePoin }; } + /** + * Constrain a position to the configured bounding shape. + */ + constrainPosition(x: number, y: number, bounds = this.bounds) { + const { shape } = this.$options; + + if (shape === 'circle' || shape === 'ellipse') { + const minX = Math.min(bounds.xMin, bounds.xMax); + const maxX = Math.max(bounds.xMin, bounds.xMax); + const minY = Math.min(bounds.yMin, bounds.yMax); + const maxY = Math.max(bounds.yMin, bounds.yMax); + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const deltaX = x - centerX; + const deltaY = y - centerY; + const radiusX = (maxX - minX) / 2; + const radiusY = (maxY - minY) / 2; + const minRadius = Math.min(radiusX, radiusY); + const constrainedRadiusX = shape === 'circle' ? minRadius : radiusX; + const constrainedRadiusY = shape === 'circle' ? minRadius : radiusY; + + if (constrainedRadiusX <= 0 || constrainedRadiusY <= 0) { + return { x: centerX, y: centerY }; + } + + const ratio = + (deltaX * deltaX) / (constrainedRadiusX * constrainedRadiusX) + + (deltaY * deltaY) / (constrainedRadiusY * constrainedRadiusY); + + if (ratio <= 1) { + return { x, y }; + } + + const scale = 1 / Math.sqrt(ratio); + + return { + x: centerX + deltaX * scale, + y: centerY + deltaY * scale, + }; + } + + return { x, y }; + } + /** * Update props when the mouse moves. */ @@ -103,9 +155,14 @@ export class Hoverable extends withRelativePoin const from = reversed ? 1 : 0; const to = reversed ? 0 : 1; + const position = this.constrainPosition( + map(clamp01(x), from, to, bounds.xMin, bounds.xMax), + map(clamp01(y), from, to, bounds.yMin, bounds.yMax), + bounds, + ); - props.y = map(clamp01(y), from, to, bounds.yMin, bounds.yMax); - props.x = map(clamp01(x), from, to, bounds.xMin, bounds.xMax); + props.x = position.x; + props.y = position.y; } /**