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

Improve responsive support #36

Merged
merged 10 commits into from
Nov 27, 2023
20 changes: 9 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,16 @@ import Visual from '@react-visual/react'
export default function ResponsiveExample() {
return (
<Visual
image="https://placehold.co/300x150"
sourceTypes={["image/webp", "image/jpeg"]}
sourceMedia={["(orientation:landscape)", "(orientation:portrait)"]}
imageLoader={({ type, media, width }) => {
const ext = type?.includes("webp") ? ".webp" : ".jpg";
const height = media?.includes("landscape") ? width * 0.5 : width;
return `https://placehold.co/${width}x${height}${ext}`;
image='https://placehold.co/200x200'
sourceTypes={['image/webp']}
sourceMedia={['(orientation:landscape)', '(orientation:portrait)']}
imageLoader={({ src, type, media, width }) => {
const height = media?.includes('landscape') ? width * 0.5 : width
const ext = type?.includes('webp') ? '.webp' : ''
return `https://placehold.co/${width}x${height}${ext}`
}}
aspect={300 / 150}
sizes="100vw"
alt="Example of responsive images"
/>
width='100%'
alt='Example of responsive images'/>
)
}
```
Expand Down
139 changes: 109 additions & 30 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default function VideoExample() {
}
```

Generate multiple landscape and portrait sources in webp and avif using an image CDN to create a srcset.
Generate multiple landscape and portrait sources using an image CDN to create a srcset.

```jsx
import Visual from '@react-visual/react'
Expand All @@ -42,11 +42,11 @@ export default function ResponsiveExample() {
return (
<Visual
image='https://placehold.co/200x200'
sourceTypes={['image/webp', 'image/jpeg']}
sourceTypes={['image/webp']}
sourceMedia={['(orientation:landscape)', '(orientation:portrait)']}
imageLoader={({ src, type, media, width }) => {
const height = media?.includes('landscape') ? width * 0.5 : width
const ext = type?.includes('webp') ? '.webp' : '.jpg'
const ext = type?.includes('webp') ? '.webp' : ''
return `https://placehold.co/${width}x${height}${ext}`
}}
width='100%'
Expand All @@ -58,30 +58,109 @@ export default function ResponsiveExample() {
The above would produce:

```html
<picture>
<source
type='image/webp'
media='(orientation:landscape)'
srcset='https://placehold.co/640x320.webp 640w, https://placehold.co/750x375.webp 750w, https://placehold.co/828x414.webp 828w, https://placehold.co/1080x540.webp 1080w, https://placehold.co/1200x600.webp 1200w, https://placehold.co/1920x960.webp 1920w, https://placehold.co/2048x1024.webp 2048w, https://placehold.co/3840x1920.webp 3840w'>
<source
type='image/webp'
media='(orientation:portrait)'
srcset='https://placehold.co/640x640.webp 640w, https://placehold.co/750x750.webp 750w, https://placehold.co/828x828.webp 828w, https://placehold.co/1080x1080.webp 1080w, https://placehold.co/1200x1200.webp 1200w, https://placehold.co/1920x1920.webp 1920w, https://placehold.co/2048x2048.webp 2048w, https://placehold.co/3840x3840.webp 3840w'>
<source
type='image/jpeg'
media='(orientation:landscape)'
srcset='https://placehold.co/640x320.jpg 640w, https://placehold.co/750x375.jpg 750w, https://placehold.co/828x414.jpg 828w, https://placehold.co/1080x540.jpg 1080w, https://placehold.co/1200x600.jpg 1200w, https://placehold.co/1920x960.jpg 1920w, https://placehold.co/2048x1024.jpg 2048w, https://placehold.co/3840x1920.jpg 3840w'>
<source
type='image/jpeg'
media='(orientation:portrait)'
srcset='https://placehold.co/640x640.jpg 640w, https://placehold.co/750x750.jpg 750w, https://placehold.co/828x828.jpg 828w, https://placehold.co/1080x1080.jpg 1080w, https://placehold.co/1200x1200.jpg 1200w, https://placehold.co/1920x1920.jpg 1920w, https://placehold.co/2048x2048.jpg 2048w, https://placehold.co/3840x3840.jpg 3840w'>
<img
src='https://placehold.co/200x200'
loading='lazy'
alt='Example of responsive images'
srcset='https://placehold.co/640x640.jpg 640w, https://placehold.co/750x750.jpg 750w, https://placehold.co/828x828.jpg 828w, https://placehold.co/1080x1080.jpg 1080w, https://placehold.co/1200x1200.jpg 1200w, https://placehold.co/1920x1920.jpg 1920w, https://placehold.co/2048x2048.jpg 2048w, https://placehold.co/3840x3840.jpg 3840w'
style='object-fit: cover; width: 100%;'>
</picture>
<div style="position: relative; width: 100%; max-width: 100%;">
<picture>
<source
type='image/webp'
media='(orientation:landscape)'
srcset='https://placehold.co/640x320.webp 640w, https://placehold.co/750x375.webp 750w, https://placehold.co/828x414.webp 828w, https://placehold.co/1080x540.webp 1080w, https://placehold.co/1200x600.webp 1200w, https://placehold.co/1920x960.webp 1920w, https://placehold.co/2048x1024.webp 2048w, https://placehold.co/3840x1920.webp 3840w'>
<source
type='image/webp'
media='(orientation:portrait)'
srcset='https://placehold.co/640x640.webp 640w, https://placehold.co/750x750.webp 750w, https://placehold.co/828x828.webp 828w, https://placehold.co/1080x1080.webp 1080w, https://placehold.co/1200x1200.webp 1200w, https://placehold.co/1920x1920.webp 1920w, https://placehold.co/2048x2048.webp 2048w, https://placehold.co/3840x3840.webp 3840w'>
<source
type='image/webp'
media='(orientation:landscape)'
srcset='https://placehold.co/640x320 640w, https://placehold.co/750x375 750w, https://placehold.co/828x414 828w, https://placehold.co/1080x540 1080w, https://placehold.co/1200x600 1200w, https://placehold.co/1920x960 1920w, https://placehold.co/2048x1024 2048w, https://placehold.co/3840x1920 3840w'>
<source
media='(orientation:portrait)'
srcset='https://placehold.co/640x640 640w, https://placehold.co/750x750 750w, https://placehold.co/828x828 828w, https://placehold.co/1080x1080 1080w, https://placehold.co/1200x1200 1200w, https://placehold.co/1920x1920 1920w, https://placehold.co/2048x2048 2048w, https://placehold.co/3840x3840 3840w'>
<img
src='https://placehold.co/200x200'
loading='lazy'
alt='Example of responsive images'
style='object-fit: cover; width: 100%;'>
</picture>
</div>
```

Accept objects from a CMS to produce responsive assets at different aspect ratios.

```jsx
import Visual from '@react-visual/react'

export default function ResponsiveExample() {
return (
<Visual
image={{
landscape: {
url: 'https://placehold.co/500x250',
aspect: 2,
},
portrait: {
url: 'https://placehold.co/500x500',
aspect: 1,
}
}}
sourceMedia={['(orientation: landscape)', '(orientation: portrait)']}
imageLoader={({ src, type, media, width }) => {

// Choose the right source
const asset = media?.includes('landscape') ?
src.landscape : src.portrait

// Make the dimensions
const dimensions = `${width}x${width / asset.aspect}`

// Choose the right format
const ext = type?.includes('webp') ? '.webp' : '.jpg'

// Make the url
return `https://placehold.co/${dimensions}${ext}`

}}
aspect={({ image, media }) => {
return media?.includes('landscape') ?
image.landscape.aspect :
image.portrait.aspect
}}
alt='Example of responsive images'/>
)
}
```

This produces:

```html
<div
class="rv-orientation-landscape-2 rv-orientation-portrait-1"
style="position: relative; max-width: 100%;">
<picture>
<source
media="(orientation: landscape)"
srcset="https://placehold.co/640x320.jpg 640w, https://placehold.co/750x375.jpg 750w, https://placehold.co/828x414.jpg 828w, https://placehold.co/1080x540.jpg 1080w, https://placehold.co/1200x600.jpg 1200w, https://placehold.co/1920x960.jpg 1920w, https://placehold.co/2048x1024.jpg 2048w, https://placehold.co/3840x1920.jpg 3840w">
<source
media="(orientation: portrait)"
srcset="https://placehold.co/640x640.jpg 640w, https://placehold.co/750x750.jpg 750w, https://placehold.co/828x828.jpg 828w, https://placehold.co/1080x1080.jpg 1080w, https://placehold.co/1200x1200.jpg 1200w, https://placehold.co/1920x1920.jpg 1920w, https://placehold.co/2048x2048.jpg 2048w, https://placehold.co/3840x3840.jpg 3840w">
<img
src="https://placehold.co/1920x1920.jpg"
loading="lazy"
alt="Example of responsive images"
style="object-fit: cover; position: absolute; inset: 0px;">
</picture>
<style>
@media (orientation: landscape) {
.rv-orientation-landscape-2 {
aspect-ratio: 2;
}
}
@media (orientation: portrait) {
.rv-orientation-portrait-1 {
aspect-ratio: 1;
}
}
</style>
</div>
```

For more examples, read [the Cypress component tests](./cypress/component).
Expand All @@ -92,15 +171,15 @@ For more examples, read [the Cypress component tests](./cypress/component).

| Prop | Type | Description
| -- | -- | --
| `image` | `string` | URL to an image asset.
| `video` | `string` | URL to a video asset asset.
| `image` | `string`, `object` | URL to an image asset.
| `video` | `string`, `object` | URL to a video asset asset.

### Layout

| Prop | Type | Description
| -- | -- | --
| `expand` | `boolean` | Make the Visual fill it's container via CSS using absolute positioning.
| `aspect` | `number` | Force the Visual to a specific aspect ratio.
| `aspect` | `number`, `function` | Force the Visual to a specific aspect ratio.
| `width` | `number`, `string` | A CSS dimension value or a px number.
| `height` | `number`, `string` | A CSS dimension value or a px number.
| `fit` | `string` | An [`object-fit`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) value that is applied to the assets. Defaults to `cover`.
Expand Down
83 changes: 80 additions & 3 deletions packages/react/cypress/component/ReactVisual.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,16 @@ describe('sources', () => {
return `https://placehold.co/${width}x${width}${ext}`
}}
aspect={1}
width='50%'
sizes='50vw'
alt=''/>)

// Should be webp source
// Should be webp source at reduced width
cy.get('img').its('[0].currentSrc')
.should('eq', 'https://placehold.co/640x640.webp')
.should('eq', 'https://placehold.co/256x256.webp')

// It should also have a fallback source
cy.get('source:not([type])').should('have.length', 1)

})

Expand All @@ -151,7 +156,7 @@ describe('sources', () => {

cy.mount(<ReactVisual
image='https://placehold.co/200x200'
sourceTypes={['image/webp', 'image/jpeg']}
sourceTypes={['image/webp']}
sourceMedia={['(orientation:landscape)', '(orientation:portrait)']}
imageLoader={({ src, type, media, width }) => {

Expand All @@ -174,6 +179,78 @@ describe('sources', () => {
cy.get('img').its('[0].currentSrc')
.should('eq', 'https://placehold.co/640x640.webp')

// There should be fallback sources (non-web) for each orientation
cy.get('source').should('have.length', 4)
cy.get('source:not([type])').should('have.length', 2)
})

it('supports rendering object based sources', () => {

// Start at a landscape viewport
cy.viewport(500, 400)

cy.mount(<ReactVisual
image={{
landscape: {
url: 'https://placehold.co/500x255?text=landscape+image',
aspect: 2,
},
portrait: {
url: 'https://placehold.co/500x505?text=portrait+image',
aspect: 1,
}
}}
sourceTypes={['image/webp']}
sourceMedia={['(orientation: landscape)', '(orientation: portrait)']}
imageLoader={({ src, type, media, width }) => {

// Choose the right source
const asset = media?.includes('landscape') ?
src.landscape : src.portrait

// Make the dimensions
const dimensions = `${width}x${width / asset.aspect}`

// Choose the right format
const ext = type?.includes('webp') ? '.webp' : '.jpg'

// Get text message from src url
const text = (new URL(asset.url)).searchParams.get('text')
+ `\\n${dimensions}${ext}`

// Make the url
return `https://placehold.co/${dimensions}${ext}?text=`+
encodeURIComponent(text)
}}
aspect={({ image, media }) => {
return media?.includes('landscape') ?
image.landscape.aspect :
image.portrait.aspect
}}
data-cy='react-visual'
alt=''/>)

// Generates a default from the first asset found
cy.get('img').invoke('attr', 'src')
.should('contain', 'https://placehold.co/1920x1920')

// Expect a landscape image
cy.get('img').its('[0].currentSrc')
.should('contain', 'https://placehold.co/640x320')
.should('contain', 'landscape')

// Check that the aspect is informing the size, not the image size
cy.get('[data-cy=react-visual]').hasDimensions(500, 250)

// Switch to portrait, which should load the other source
cy.viewport(500, 600)
cy.get('img').its('[0].currentSrc')
.should('contain', 'https://placehold.co/640x640')
.should('contain', 'portrait')

// Check aspect again
cy.get('[data-cy=react-visual]').hasDimensions(500, 500)

})

})
29 changes: 28 additions & 1 deletion packages/react/cypress/component/VisualWrapper.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ const sharedProps = {
style: { background: 'black', color: 'white' },
className: 'wrapper',
}
const style = { background: 'black' }

// Viewport sizes
const VW = Cypress.config('viewportWidth'),
Expand Down Expand Up @@ -46,6 +45,34 @@ it('supports aspect', () => {
cy.get('.wrapper').hasDimensions(VW, VH / 2)
})

it('supports respponsive aspect function', () => {
cy.mount(<VisualWrapper
{...sharedProps }
image={{
landscape: {
aspect: 2,
},
portrait: {
aspect: 1,
}
}}
sourceMedia={[
'(orientation: landscape)',
'(orientation: portrait)'
]}
aspect={({ image, media }) => {
return media?.includes('landscape') ?
image.landscape.aspect :
image.portrait.aspect
}}
/>)
cy.viewport(500, 400)
cy.get('.wrapper').hasDimensions(500, 250)
cy.viewport(400, 500)
cy.get('.wrapper').hasDimensions(400, 400)
})


it('supports children', () => {
cy.mount(<VisualWrapper {...sharedProps }>
<h1>Hey</h1>
Expand Down
1 change: 1 addition & 0 deletions packages/react/cypress/support/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Cypress.Commands.add('hasDimensions',
Cypress.Commands.add('imgLoaded',
{ prevSubject: true },
(subject) => {
cy.wait(100) // Wait a tick to solve for inexplicable flake
cy.wrap(subject)
.should('be.visible')
.and('have.prop', 'naturalWidth')
Expand Down
Loading