+ 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;
}
/**