Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add object-fit util #95

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
2 changes: 1 addition & 1 deletion src/hooks/useHasFocus/useHasFocus.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable react/jsx-no-literals */
/* eslint-disable react/no-multi-comp */
import { act, render, waitFor } from '@testing-library/react';
import { act, render } from '@testing-library/react';
import { useRef, type ReactElement } from 'react';
import { useHasFocus } from './useHasFocus.js';

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ export * from './hooks/useToggle/useToggle.js';
export * from './hooks/useUnmount/useUnmount.js';
export * from './hooks/useWindowEventListener/useWindowEventListener.js';
export * from './utils/arrayRef/arrayRef.js';
export * from './utils/objectFit/objectFit.js';
155 changes: 155 additions & 0 deletions src/utils/objectFit/objectFit.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { Meta } from '@storybook/blocks';

<Meta title="utils/objectFit" />

# objectFit

This util mimics the CSS property `object-fit` for all HTML elements;

It exports two reusable methods: `contain` and `cover`. Given the sizes of an parent element and its
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
It exports two reusable methods: `contain` and `cover`. Given the sizes of an parent element and its
It exports two reusable methods: `contain` and `cover`. Given the sizes of a parent element and its

child element: Contain returns the size to be applied to the element to let it fits its parent and
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
child element: Contain returns the size to be applied to the element to let it fits its parent and
child element: Contain returns the size to be applied to the element to let it fit its parent and

keeping its apect ratio. Cover returns the size to be applied to the element to let it fill its
parent, keeping its aspect ratio, most likely overflowing the parent element.

If the sizes or aspect ratio are initially known, it's better to use values instead of retrieving
sizes from an image because its faster from a performance perspective.

## Reference

```ts
function objectFit(fit: 'contain' | 'cover') {
return (
parentWidth: number,
parentHeight: number,
childWidth: number,
childHeight: number,
): { x: number; y: number; width: number; height: number; scale: number; cssText: string } => {
if ([parentWidth, parentHeight, childWidth, childHeight].some((value) => value <= 0)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using logical operators will be significantly faster compared to Array.some.

if (parentWidth <= 0 || parentHeight <= 0 || childWidth <= 0 || childHeight <= 0) {
  throw new Error(`All arguments should have a positive value`);
}

throw new Error(`All arguments should have a positive value`);
}

const mathMethod = fit === 'contain' ? Math.min : Math.max;
const scale = mathMethod(parentWidth / childWidth, parentHeight / childHeight);
const width = Math.ceil(childWidth * scale);
const height = Math.ceil(childHeight * scale);
const x = Math.trunc((parentWidth - width) * 0.5);
const y = Math.trunc((parentHeight - height) * 0.5);

return {
x,
y,
width,
height,
scale,
cssText: `left:${x}px;top:${y}px;width:${width}px;height:${height}px;`,
};
};
}

export const contain = objectFit('contain');
export const cover = objectFit('cover');
```

### Parameters

- parentWidth: number
- parentHeight: number
- childWidth: number
- childHeight: number

### Returns

An object containing:

- x: number
- y: number
- width: number
- height: number
- scale: number
- cssText: string (easily add CSS values to child element)

## Usage

Contain:

With the contain method you can use both position absolute and relative on the child element.
Relative can be useful if you want to position elements inside absolute to the parent.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relative can be useful if you want to position elements inside absolute

That sounds a bit strange? :)


```tsx
import { contain } from './objectFit.js';

export function Contain(): ReactElement {
const parentRef = useRef<HTMLDivElement>(null);
const childRef = useRef<HTMLDivElement>(null);

const onResize = useCallback(() => {
if (!parentRef.current || !childRef.current) {
return;
}

const objectFit = contain(parentRef.current.offsetWidth, parentRef.current.offsetHeight, 1, 1);

childRef.current.style.cssText += objectFit.cssText;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even though this is just an example, it could happen that if the parentRef changes, it appends the new cssText to an existing element.

I was thinking the csstext would be used to assign to the JSX with the style tag, but unfortunately that receives an object, and not a text string.

I wonder if it would be useful to have an example like:

const { x: left, y: top, width, height } = contain(parentRef.current.offsetWidth, parentRef.current.offsetHeight, 1, 1);


<div
  ref={childRef}
  style={{ position: 'relative', outline: '1px solid blue', left, top, width, height }}
/>

Or maybe have a cssObj returned with those 4 renamed style props, so it can be used in a similar way to cssText;

Benefit over JSX is that it sets the individual style props (left, top, etc), instead of working with a "dumb" string.

}, [parentRef, childRef]);

useResizeObserver(parentRef, onResize);

return (
<div
ref={parentRef}
style={{
width: '100px',
height: '100px',
outline: '1px solid green',
position: 'relative',
}}
>
<div ref={childRef} style={{ position: 'relative', outline: '1px solid blue' }} />
</div>
);
}
```

Cover:

With contain you need to use position absolute to position the child.

```tsx
import { cover } from './objectFit.js';

export function Cover(): ReactElement {
const parentRef = useRef<HTMLDivElement>(null);
const childRef = useRef<HTMLDivElement>(null);

const onResize = useCallback(() => {
if (!parentRef.current || !childRef.current) {
return;
}

const objectFit = cover(
parentRef.current.offsetWidth,
parentRef.current.offsetHeight,
1920,
1080,
);

childRef.current.style.cssText += objectFit.cssText;
}, [parentRef, childRef]);

useResizeObserver(parentRef, onResize);

return (
<div
ref={parentRef}
style={{
width: '100px',
height: '100px',
outline: '1px solid green',
position: 'relative',
}}
>
<div ref={childRef} style={{ position: 'absolute', outline: '1px solid blue' }} />
</div>
);
}
```
101 changes: 101 additions & 0 deletions src/utils/objectFit/objectFit.stories.tsx

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions src/utils/objectFit/objectFit.test.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're having two tests, one for cover, one for contain.
Both tests have the exact same input, and the exact same output.
How can you be 100% sure, from this tests, that both versions work correctly? :)

Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { contain, cover } from './objectFit.js';

describe('objectFit', () => {
describe('contain', () => {
it('returns expected values for positive input arguments', () => {
expect(contain(100, 100, 50, 50)).toEqual({
x: 0,
y: 0,
width: 100,
height: 100,
scale: 2,
cssText: 'left:0px;top:0px;width:100px;height:100px;',
});
});

it('throws an error for non-positive input arguments', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically, 0 is positive.
Maybe reword (and also in other places) to "greater than 0" ?

expect(() => contain(1, 1, 1, 0)).toThrow('All arguments should have a positive value');
});
});

describe('cover', () => {
it('returns expected values for positive input arguments', () => {
expect(cover(100, 100, 50, 50)).toEqual({
x: 0,
y: 0,
width: 100,
height: 100,
scale: 2,
cssText: 'left:0px;top:0px;width:100px;height:100px;',
});
});

it('throws an error for non-positive input arguments', () => {
expect(() => cover(1, 1, -1, 1)).toThrow('All arguments should have a positive value');
});
});
});
31 changes: 31 additions & 0 deletions src/utils/objectFit/objectFit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
function objectFit(fit: 'contain' | 'cover') {
return (
parentWidth: number,
parentHeight: number,
childWidth: number,
childHeight: number,
): { x: number; y: number; width: number; height: number; scale: number; cssText: string } => {
if ([parentWidth, parentHeight, childWidth, childHeight].some((value) => value <= 0)) {
throw new Error(`All arguments should have a positive value`);
}

const mathMethod = fit === 'contain' ? Math.min : Math.max;
const scale = mathMethod(parentWidth / childWidth, parentHeight / childHeight);
const width = Math.ceil(childWidth * scale);
const height = Math.ceil(childHeight * scale);
const x = Math.trunc((parentWidth - width) * 0.5);
const y = Math.trunc((parentHeight - height) * 0.5);

return {
x,
y,
width,
height,
scale,
cssText: `left:${x}px;top:${y}px;width:${width}px;height:${height}px;`,
};
};
}

export const contain = objectFit('contain');
export const cover = objectFit('cover');