-
Notifications
You must be signed in to change notification settings - Fork 31
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #7129 from oliverlloyd/oliver/lightbox
Lightbox
- Loading branch information
Showing
33 changed files
with
2,959 additions
and
67 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,244 @@ | ||
/* eslint-disable no-undef */ | ||
/* eslint-disable func-names */ | ||
import { mockApi } from '../../lib/mocks'; | ||
import { disableCMP } from '../../lib/disableCMP'; | ||
import { setLocalBaseUrl } from '../../lib/setLocalBaseUrl.js'; | ||
|
||
const articleUrl = | ||
'https://www.theguardian.com/artanddesign/2022/dec/26/a-lighter-side-of-life-picture-essay'; | ||
|
||
const liveblogUrl = | ||
'https://www.theguardian.com/science/live/2021/feb/19/mars-landing-nasa-perseverance-rover-briefing-latest-live-news-updates'; | ||
|
||
describe('Lightbox', function () { | ||
beforeEach(function () { | ||
disableCMP(); | ||
setLocalBaseUrl(); | ||
}); | ||
|
||
it('should open the lightbox when an expand button is clicked', function () { | ||
cy.visit(`/Article/${articleUrl}`); | ||
cy.get('#gu-lightbox').should('not.be.visible'); | ||
// Open lightbox using the second button on the page (the first is main media) | ||
cy.get('button.open-lightbox').eq(1).realClick(); | ||
cy.get('#gu-lightbox').should('be.visible'); | ||
// We expect the second image to be showing because the first is the main media | ||
// which doesn't have a button in this case because it's an immersive article. | ||
cy.get('nav [data-cy="lightbox-selected"]').contains('2/22'); | ||
cy.get('li[data-index="2"] img').should('be.visible'); | ||
}); | ||
|
||
it('should open the lightbox when an image is clicked', function () { | ||
cy.visit(`/Article/${articleUrl}`); | ||
cy.get('#gu-lightbox').should('not.be.visible'); | ||
// Open lightbox using fifth image on the page | ||
cy.get('article img').eq(3).realClick(); | ||
cy.get('#gu-lightbox').should('be.visible'); | ||
cy.get('nav [data-cy="lightbox-selected"]').contains('5/22'); | ||
cy.get('li[data-index="5"] img').should('be.visible'); | ||
}); | ||
|
||
it('should trap focus', function () { | ||
cy.visit(`/Article/${articleUrl}`); | ||
cy.get('article img').first().realClick(); | ||
cy.get('#gu-lightbox').should('be.visible'); | ||
cy.realPress('Tab'); | ||
cy.get('button.close').should('have.focus'); | ||
cy.get('button.next').should('not.have.focus'); | ||
cy.realPress('Tab'); | ||
cy.get('button.next').should('have.focus'); | ||
cy.realPress('Tab'); | ||
cy.realPress('Tab'); | ||
cy.get('button.info').should('have.focus'); | ||
cy.realPress('Tab'); | ||
// This is where focus should wrap back | ||
cy.get('button.info').should('not.have.focus'); | ||
cy.get('button.close').should('have.focus'); | ||
cy.realPress('Tab'); | ||
cy.get('button.next').should('have.focus'); | ||
cy.realPress('Tab'); | ||
cy.get('button.previous').should('have.focus'); | ||
cy.realPress('Tab'); | ||
cy.get('button.info').should('have.focus'); | ||
cy.realPress('Tab'); | ||
cy.get('button.close').should('have.focus'); | ||
cy.realPress('Tab'); | ||
cy.get('button.next').should('have.focus'); | ||
cy.realPress('Tab'); | ||
cy.get('button.previous').should('have.focus'); | ||
}); | ||
|
||
it('should respond to keyboard shortcuts and image clicks', function () { | ||
cy.visit(`/Article/${articleUrl}`); | ||
cy.get('#gu-lightbox').should('not.be.visible'); | ||
// Open lightbox using the second button on the page (the first is main media) | ||
cy.get('button.open-lightbox').eq(1).realClick(); | ||
cy.get('#gu-lightbox').should('be.visible'); | ||
// Close lightbox using q key | ||
cy.realPress('q'); | ||
cy.get('#gu-lightbox').should('not.be.visible'); | ||
cy.get('button.open-lightbox').eq(1).realClick(); | ||
// We should be able to navigate using arrow keys | ||
cy.get('li[data-index="3"]').should('not.be.visible'); | ||
cy.realPress('ArrowRight'); | ||
cy.get('li[data-index="3"]').should('be.visible'); | ||
cy.realPress('ArrowRight'); | ||
cy.get('li[data-index="3"]').should('not.be.visible'); | ||
cy.get('li[data-index="4"]').should('be.visible'); | ||
cy.realPress('ArrowRight'); | ||
cy.realPress('ArrowRight'); | ||
cy.realPress('ArrowRight'); | ||
cy.get('li[data-index="7"]').should('be.visible'); | ||
cy.realPress('ArrowLeft'); | ||
cy.get('li[data-index="6"]').should('be.visible'); | ||
cy.realPress('ArrowLeft'); | ||
cy.realPress('ArrowLeft'); | ||
cy.realPress('ArrowLeft'); | ||
cy.realPress('ArrowLeft'); | ||
cy.realPress('ArrowLeft'); | ||
// Going further back from position 1 should take us | ||
// round to the end and vice versa | ||
cy.realPress('ArrowLeft'); | ||
cy.get('li[data-index="22"]').should('be.visible'); | ||
cy.realPress('ArrowRight'); | ||
cy.get('li[data-index="1"]').should('be.visible'); | ||
// Showing and hiding the info caption using 'i' | ||
cy.get('li[data-index="1"] aside').should('be.visible'); | ||
cy.realPress('i'); | ||
cy.get('li[data-index="1"] aside').should('not.be.visible'); | ||
cy.realPress('i'); | ||
cy.get('li[data-index="1"] aside').should('be.visible'); | ||
// Showing and hiding the caption by clicking | ||
cy.get('li[data-index="1"] picture').click(); | ||
cy.get('li[data-index="1"] aside').should('not.be.visible'); | ||
cy.get('li[data-index="1"] picture').click(); | ||
cy.get('li[data-index="1"] aside').should('be.visible'); | ||
// Showing and hiding using arrow keys | ||
cy.realPress('ArrowDown'); | ||
cy.get('li[data-index="1"] aside').should('not.be.visible'); | ||
cy.realPress('ArrowUp'); | ||
cy.get('li[data-index="1"] aside').should('be.visible'); | ||
// Closing the lightbox using escape | ||
cy.realPress('Escape'); | ||
cy.get('#gu-lightbox').should('not.be.visible'); | ||
}); | ||
|
||
it('should download adjacent images before they are viewed', function () { | ||
/** A mini abstraction to make this particular test more readable */ | ||
function image(position) { | ||
return cy.get(`li[data-index="${position}"] img`); | ||
} | ||
cy.visit(`/Article/${articleUrl}`); | ||
// eq(6) here means the 7th button is clicked (base zero you | ||
// see) | ||
cy.get('button.open-lightbox').eq(6).click(); | ||
// We validate that adjacent images get downloaded early by checking the | ||
// value of the `loading` attribute | ||
image(5).should('have.attr', 'loading', 'lazy'); | ||
image(6).should('have.attr', 'loading', 'eager'); | ||
image(7).should('be.visible'); | ||
image(8).should('have.attr', 'loading', 'eager'); | ||
image(9).should('have.attr', 'loading', 'lazy'); | ||
// Move to the next image - position 8 | ||
cy.realPress('ArrowRight'); | ||
image(6).should('have.attr', 'loading', 'eager'); // Once eager, it stays this way | ||
image(7).should('have.attr', 'loading', 'eager'); | ||
image(8).should('be.visible'); | ||
image(9).should('have.attr', 'loading', 'eager'); | ||
image(10).should('have.attr', 'loading', 'lazy'); | ||
// Move to the next image - position 9 | ||
cy.realPress('ArrowRight'); | ||
image(10).should('have.attr', 'loading', 'eager'); | ||
image(11).should('have.attr', 'loading', 'lazy'); | ||
// Move to the next image - position 10 | ||
cy.realPress('ArrowRight'); | ||
image(11).should('have.attr', 'loading', 'eager'); | ||
image(12).should('have.attr', 'loading', 'lazy'); | ||
}); | ||
|
||
it('should remember my preference for showing the caption', function () { | ||
cy.visit(`/Article/${articleUrl}`); | ||
cy.get('button.open-lightbox').eq(1).realClick(); | ||
// The info aside is visible by default | ||
cy.get('li[data-index="2"] aside').should('be.visible'); | ||
// Clicking an image toggles the caption | ||
cy.get('li[data-index="2"] img').click(); | ||
cy.get('li[data-index="2"] aside').should('not.be.visible'); | ||
// Close lightbox | ||
cy.realPress('Escape'); | ||
// Re-open lightbox to see if the info aside element is now open | ||
cy.get('button.open-lightbox').eq(1).realClick(); | ||
cy.get('li[data-index="2"] aside').should('not.be.visible'); | ||
// Close lightbox | ||
cy.realPress('Escape'); | ||
// Reload the page to see if my preference for having the caption hidden | ||
// has been preserved | ||
cy.visit(`/Article/${articleUrl}`); | ||
cy.get('button.open-lightbox').eq(1).realClick(); | ||
cy.get('#gu-lightbox').should('be.visible'); | ||
cy.get('li[data-index="2"] aside').should('not.be.visible'); | ||
// Turn the info aside back off and then reload once more to check the | ||
// caption is again showing by default | ||
cy.realPress('i'); | ||
cy.visit(`/Article/${articleUrl}`); | ||
cy.get('button.open-lightbox').eq(1).realClick(); | ||
cy.get('li[data-index="2"] aside').should('be.visible'); | ||
}); | ||
|
||
it('should be possible to navigate by scrolling', function () { | ||
cy.visit(`/Article/${articleUrl}`); | ||
cy.get('button.open-lightbox').eq(1).realClick(); | ||
cy.get('li[data-index="2"] img').should('be.visible'); | ||
cy.get('li[data-index="5"]').scrollIntoView(); | ||
cy.get('li[data-index="2"] img').should('not.be.visible'); | ||
cy.get('li[data-index="5"] img').should('be.visible'); | ||
cy.get('nav [data-cy="lightbox-selected"]').contains('5/22'); | ||
cy.get('li[data-index="1"]').scrollIntoView(); | ||
cy.get('li[data-index="1"] img').should('be.visible'); | ||
cy.get('nav [data-cy="lightbox-selected"]').contains('1/22'); | ||
}); | ||
|
||
it('should navigate to the original block when clicking links in captions', function () { | ||
cy.visit(`/Article/${liveblogUrl}`); | ||
cy.get('button.open-lightbox').eq(1).realClick(); | ||
cy.get('#gu-lightbox').should('be.visible'); | ||
// The info aside is visible by default | ||
cy.get('li[data-index="2"] aside').should('be.visible'); | ||
// Click the caption link | ||
cy.get('li[data-index="2"] aside a').click(); | ||
cy.url().should( | ||
'include', | ||
'?page=with:block-603007c48f08c3cb92a5ca74#block-603007c48f08c3cb92a5ca74', | ||
); | ||
}); | ||
|
||
it('should use the url to maintain lightbox position', function () { | ||
cy.visit(`/Article/${articleUrl}`); | ||
cy.get('button.open-lightbox').eq(1).realClick(); | ||
cy.get('nav [data-cy="lightbox-selected"]').contains('2/22'); | ||
cy.url().should('contain', '#img-2'); | ||
cy.realPress('ArrowRight'); | ||
cy.url().should('contain', '#img-3'); | ||
cy.realPress('ArrowRight'); | ||
cy.realPress('ArrowRight'); | ||
cy.realPress('ArrowRight'); | ||
cy.url().should('contain', '#img-6'); | ||
// It should load the lightbox at page load if the url contains an img hash | ||
cy.reload(); | ||
cy.get('#gu-lightbox').should('be.visible'); | ||
cy.get('nav [data-cy="lightbox-selected"]').contains('6/22'); | ||
}); | ||
|
||
it('should trigger the lightbox to close or open if the user navigates back and forward', function () { | ||
cy.visit(`/Article/${articleUrl}`); | ||
cy.get('button.open-lightbox').eq(1).realClick(); | ||
cy.get('nav [data-cy="lightbox-selected"]').contains('2/22'); | ||
cy.url().should('contain', '#img-2'); | ||
cy.go('back'); | ||
cy.get('#gu-lightbox').should('not.be.visible'); | ||
cy.url().should('not.contain', '#img-'); | ||
cy.go('forward'); | ||
cy.get('#gu-lightbox').should('be.visible'); | ||
cy.url().should('contain', '#img-2'); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
# Lightbox 💡 | ||
|
||
Lightbox is an overlay that appears above the page to showcase image content. | ||
|
||
https://github.com/guardian/dotcom-rendering/pull/7129 | ||
|
||
## Useful links | ||
|
||
The [frontend version of lightbox](https://github.com/guardian/frontend/blob/main/static/src/javascripts/projects/common/modules/gallery/lightbox.js) works in a similar way and was used as the basis for DCR's version | ||
|
||
Frontend's lightbox was added to the Guardian's website in 2014. [The PR to add this](https://github.com/guardian/frontend/pull/5934) has a lot of useful information in it. | ||
|
||
## Features | ||
|
||
### Parity with Frontend | ||
|
||
- History navigation closes lightbox | ||
- Permalinks to images | ||
- Traps focus | ||
- Optimises image loading | ||
- Images are scaled to the viewport | ||
|
||
### New in DCR | ||
|
||
- Native scrolling | ||
- Defers hydration | ||
- Fullscreen API | ||
- Deduplicates images | ||
- Links to posts in liveblogs | ||
- Remembers display preferences for caption | ||
|
||
## How it gets hydrated | ||
|
||
On the server, we only insert a small amount of html and zero javascript. This html can be found in LightboxLayout. On the client hydration is trigged when the page url contains a hash that martches an expected pattern, namely, `img-[n]`. Using the `deferUntil='hash'` feature of Islands, we only execute the Lightbox javascript at the point the url changes and lightbox is requested. | ||
|
||
## How it works | ||
|
||
### The url is the source of truth | ||
|
||
Lightbox works by using the url as the source of truth. If and when the url contains a hash in the form `img-[n]` then hydration is triggered (if it hasn't already happened) and the lightbox is opened. As soon as the url does not contain this hash, the lightbox is closed. | ||
|
||
You open and close the lightbox by changing the url. When a reader clicks the 'close' button inside the lightbox there is some javascript that fires the `history.back()` function. Manually editing the url or pressing the back button in the browser is therefor identical to clicking 'close'. | ||
|
||
### `LightboxLink` | ||
|
||
This file is used on the server to co er each article image with an <a> tag. No javascript is used here, only platform features and css. | ||
|
||
If the image is clicked the a tag captured this and mutates the url to add a hash in the form `img-[n]`. This triggers hydration based on the `deferUntil='hash'` island. | ||
|
||
### `LightboxLayout` | ||
|
||
On the server, we only render the basic furniture for the lightbox. Enough for us to be able to open the lightbox, fill the screen and show any controls. But this html does not include any images and is not hydrated. | ||
|
||
### `LightboxImages` | ||
|
||
We don't want to render the html for all of the images on the server because the size of the html produced by the Picture component is considerable. So instead we break this html out into a separate file and dynamically insert it into the dom during hydration. | ||
|
||
We use React.createPortal to insert the resulting html from the `LightboxImages` component into a placeholder that exists inside `LightboxLayout`. This placeholder is called `ul#lightbox-images`. | ||
|
||
### `LightboxCaption` | ||
|
||
Captions in lightbox have sufficient deviation in style to have their own component. | ||
|
||
### `LightboxHash.importable` | ||
|
||
This small file is placed in an `Island` inside `ArticlePage` and executed immediately. It is not deferred. | ||
|
||
It does one thing. It checks to see if the page load is happening with a url that already containd a lightbox hash. If it does, it handles an edge case by mutating history state. Normally, the lightbox hash is introduced to the url by clicking an image but maybe the reader refreshed the page or perhaps they were sent a link with a hash on it. | ||
|
||
What is the edge case? | ||
Because we use the url as the source of truth for lightbox it means we close the lightbox by using `history.back`. This works fine when the reader started on an article and then clicked an image because it means the place they go 'back' to is the article url without the hash. But if you directly load a url with a hash already on it then the place you will go 'back' to is probably a blank page (or just where you were when you entered the url). | ||
|
||
The fix here is to mutate the history state by adding a new entry so that when `history.back()` gets fired the reader ends up on the article. | ||
|
||
### `LightboxJavascript.importable` | ||
|
||
This file contains the logic for how lightbox operates. | ||
|
||
The React aspect of this file is small. It immediately returns a call to React.createPortal to insert the `LightboxImages` html and then executes a useEffect to run the `initialiseLightbox` function. | ||
|
||
Both of these calls only happen once. There is no react state to trigger re-renders and the Islands archetecure prevents hydration repeating using the `data-gu-ready` flag. This flag is set at the end of the `initialiseLightbox` function. | ||
|
||
#### `initialiseLightbox` | ||
|
||
This file sets a series of event listeners and when these are triggered it runs functions. The listeners are: | ||
|
||
`scroll` | ||
When the list of images is scrolled to a new position, either manually or by clicking the pervious/next buttons, then there is a new image being shown so we: | ||
|
||
- Update the visual indication showing which image is selected | ||
- Edit the url hash to point to this new `img-[position]` | ||
- Start to eager load any adjacent images | ||
|
||
`popstate` | ||
When the reader clicks back or forward, the popstate event is fired. This can happen when the lightbox is open or closed. When it does we either open or close the lightbox based on if it has an `img-[n]` hash or not. | ||
|
||
`change` (FullscreenAPI) | ||
This event is fired when fullscreen mode changes. This can either be because it opened or closed. This listener check if it is closing and if it is, it closes the lightbox. | ||
|
||
We do this because it is possible to exit fullscreen mode independently of the lightbox code. | ||
|
||
`load` | ||
An `<img>` tag will fire this 'load' event when the image that it depends on has been downloaded. We use this event to remove the loading screen that we render over each image (`LightboxLoader`) | ||
|
||
`click` or `mousedown` | ||
There are four controls on lightbox which respond to click or mousedown events. | ||
|
||
- close - Closes the lightbox by calling `history.back()` | ||
- next - Navigates inside the lightbox using `scrollTo()`, you can also just manually scroll, it's the same | ||
- previous - Navigates inside the lightbox using `scrollTo()`, you can also just manually scroll, it's the same | ||
- toggle info - Show / Hide the info `aside` element | ||
|
||
The next and previous actions trigger the `scroll` event. | ||
|
||
`keypress` | ||
We capture a series of key presses and use them as shortcuts for other actions within the lightbox. These are better documented in the code. | ||
|
||
### `LightboxLoader` | ||
|
||
Returns a div that shows an animated loader above each lightbox image. This loader is removed if the `img.complete` state is true or when the `load` event is fired for an image. | ||
|
||
This prevents an empty screen showing for readers on slower connections. | ||
|
||
## Next steps | ||
|
||
Add the ability to render all images from a liveblog and all images from a series. | ||
See: https://github.com/guardian/frontend/pull/20462 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.