Skip to content

Commit

Permalink
Merge pull request #7129 from oliverlloyd/oliver/lightbox
Browse files Browse the repository at this point in the history
Lightbox
  • Loading branch information
jamesgorrie authored Jul 6, 2023
2 parents b876fdf + e61f6b6 commit 1aaf10b
Show file tree
Hide file tree
Showing 33 changed files with 2,959 additions and 67 deletions.
244 changes: 244 additions & 0 deletions dotcom-rendering/cypress/e2e/parallel-2/lightbox.cy.js
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');
});
});
1 change: 1 addition & 0 deletions dotcom-rendering/cypress/support/e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
// Import commands.js using ES2015 syntax:
import './commands';
import 'cypress-plugin-tab';
import 'cypress-real-events';

// Alternatively you can use CommonJS syntax:
// require('./commands')
Expand Down
127 changes: 127 additions & 0 deletions dotcom-rendering/docs/lightbox.md
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
2 changes: 2 additions & 0 deletions dotcom-rendering/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@
"curlyquotes": "1.5.5",
"cypress": "12.16.0",
"cypress-plugin-tab": "1.0.5",
"cypress-real-events": "1.7.6",
"cypress-wait-until": "1.7.2",
"desvg-loader": "0.1.0",
"doctoc": "2.2.1",
Expand Down Expand Up @@ -239,6 +240,7 @@
"rimraf": "3.0.2",
"sanitize-html": "2.11.0",
"scheduler": "0.23.0",
"screenfull": "5.2.0",
"serve-static": "1.15.0",
"simple-progress-webpack-plugin": "2.0.0",
"snyk": "1.1103.0",
Expand Down
Loading

0 comments on commit 1aaf10b

Please sign in to comment.