Use the gallery component within content areas or the @uol-widget-gallery to display single or groups of images, videos or combinations of both images and videos. Images and videos can also have an accompanying caption.
In addition to the image or video each item can have an accompanying title and text content.
Clicking on the item expand button will open the item in a full-screen carousel. If a title and text content is available these can be accessed by clicking in the “I” button for the corresponding carousel item.
For videos YouTube is currently the only supported platform.
Use the Gallery component when you want to present images or videos and have them viewable in full screen.
For performance and image quality it is recommended that a lower and higher resolution version of each image is provided. e.g.
"img": {
"srcHighQuality": "/placeholders/campus/full/29940.jpeg",
"src": "/placeholders/campus/medium/29940.jpeg",
...
},
For photographic images JPEG compression should be set to 85% quality
For graphical images PNG format should be used.
Ensure you set the item type to video.
At this point the only video host supported is YouTube. You can use full youtube URLS or the shortened versions. Eg. Both the following will display the YouTube video with id PpilTVi5Yk4
Do not use the ‘embed’ code that YouTube provides as this will not work within the @uol-gallery component. The component makes use of the oEmbed API that YouTube provides.
Do not provide an image for videos as video images are retrieved directly from YouTube. You may provide a caption.
{
"title": "Ear tickle therapy and the world's thinnest gold",
"type": "video",
"video": "https://www.youtube.com/watch?v=PpilTVi5Yk4",
"img": {
"caption": "Ear tickle therapy and the world's thinnest gold",
},
...
}
In certain scenarios (following guidance from the design team) there maybe a need to replace the usual ‘expanded’ icon for the larger orange one with the play button icon.
This can be done through the config by setting the ‘videoPlayIcon: true’:
{
"name": "Single video with play icon",
"label": "Single video with play icon",
"context": {
"gallery": {
"headingLevel": 2,
"videoPlayIcon": true,
"items": galleryVideos.slice(2, 3),
},
},
}
Please see the ‘with video’ variant of the @featured-content for more details.
{% if gallery.items.length %}
{% set headingTag = 'h' + gallery.headingLevel if gallery.headingLevel else 'h3' %}
<section class="uol-gallery-container" aria-label="Gallery of {{ gallery.items.length }} items">
<div class="uol-gallery uol-gallery--count-{{ gallery.items.length }}">
{% for item in gallery.items %}
<div class="uol-gallery__item {{ 'uol-gallery__item--' + item.type if item.type }} {{ 'uol-gallery__item--image' if item.img.src }} {{ 'uol-gallery__item--video-play-icon' if gallery.videoPlayIcon }}"
{% if item.type and item.video %} data-video="{{ item.video }}" {% endif %}>
<{{ headingTag }} class="uol-gallery__item__title">{{ item.title | safe }}</{{ headingTag }}>
<figure class="uol-gallery__figure">
<div class="uol-gallery__image-container">
<img
src="{{ item.img.src if item.img.src else '#' }}"
alt="{{ item.img.alt }}"
{% if item.img.srcHighQuality %} data-src-high-quality="{{ item.img.srcHighQuality }}" {% endif %}>
</div>
{% if item.img.caption %}
<figcaption class="uol-gallery__image-caption">{{ item.img.caption | safe }}</figcaption>
{% endif %}
</figure>
{% if item.type and item.video %}
<noscript>
<a href="{{ item.video }}">{{ item.video }}</a>
</noscript>
{% endif %}
{% if item.content %}
<div class="uol-gallery__item__content">
{{ item.content | safe }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
</section>
{% endif %}
<section class="uol-gallery-container" aria-label="Gallery of 3 items">
<div class="uol-gallery uol-gallery--count-3">
<div class="uol-gallery__item uol-gallery__item--image ">
<h2 class="uol-gallery__item__title">“Dual Form” by Barbara Hepworth</h2>
<figure class="uol-gallery__figure">
<div class="uol-gallery__image-container">
<img src="/placeholders/campus/medium/29940.jpeg" alt="Dual Form sculpture by Barbara Hepworth with people relaxing on grass in background" data-src-high-quality="/placeholders/campus/full/29940.jpeg">
</div>
<figcaption class="uol-gallery__image-caption">“Dual Form” by Barbara Hepworth</figcaption>
</figure>
<div class="uol-gallery__item__content">
<p>Lorem ipsum <a href='/some-text-link'>dolor sit amet consectetur</a> adipisicing elit. Corrupti, quasi nostrum blanditiis hic totam a id architecto molestias, sunt vitae iste consectetur cupiditate incidunt autem illo, consequuntur recusandae? Ipsam, asperiores.</p>
<p>Expedita saepe illo vero sit et! Eveniet, deserunt. Nihil omnis fugit ut veniam ullam, non maiores, consequatur amet enim dolore totam, laborum accusantium voluptatum iure est ab aspernatur reiciendis explicabo.</p>
<p>Eos reprehenderit suscipit, at eveniet, minus ea quod quis provident, nisi fugit maiores molestias culpa. Rerum rem pariatur quo mollitia autem omnis eum officiis, natus beatae eos saepe culpa earum!</p>
<p>Eum ut tempore delectus quos unde tenetur neque perspiciatis. Dicta sunt rem dolore, in ab impedit assumenda, quaerat neque quos veritatis consequatur accusantium dignissimos eius natus iusto nostrum eos maxime.</p>
</div>
</div>
<div class="uol-gallery__item uol-gallery__item--image ">
<h2 class="uol-gallery__item__title">Edward Boyle Library</h2>
<figure class="uol-gallery__figure">
<div class="uol-gallery__image-container">
<img src="/placeholders/campus/medium/28573.jpeg" alt="Steps outside Edward Boyle Library" data-src-high-quality="/placeholders/campus/full/28573.jpeg">
</div>
</figure>
<div class="uol-gallery__item__content">
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Corrupti, quasi nostrum blanditiis hic totam a id architecto molestias, sunt vitae iste consectetur cupiditate incidunt autem illo, consequuntur recusandae? Ipsam, asperiores.</p>
<p>Expedita saepe illo vero sit et! Eveniet, deserunt. Nihil omnis fugit ut veniam ullam, non maiores, consequatur amet enim dolore totam, laborum accusantium voluptatum iure est ab aspernatur reiciendis explicabo.</p>
<p>Eos reprehenderit suscipit, at eveniet, <a href='/some-text-link'>minus ea quod</a> quis provident, nisi fugit maiores molestias culpa. Rerum rem pariatur quo mollitia autem omnis eum officiis, natus beatae eos saepe culpa earum!</p>
<p>Eum ut tempore delectus quos unde tenetur neque perspiciatis. Dicta sunt rem dolore, in ab impedit assumenda, quaerat neque quos veritatis consequatur accusantium dignissimos eius natus iusto nostrum eos maxime.</p>
</div>
</div>
<div class="uol-gallery__item uol-gallery__item--image ">
<h2 class="uol-gallery__item__title">Victoria Quarter Arcade, Leeds</h2>
<figure class="uol-gallery__figure">
<div class="uol-gallery__image-container">
<img src="/placeholders/campus/medium/29936.jpeg" alt="Interior of Victoria Quarter Arcade" data-src-high-quality="/placeholders/campus/full/29936.jpeg">
</div>
<figcaption class="uol-gallery__image-caption">Victoria Quarter</figcaption>
</figure>
<div class="uol-gallery__item__content">
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Corrupti, quasi nostrum blanditiis hic totam a id architecto molestias, sunt vitae iste consectetur cupiditate incidunt autem illo, <a href='/some-text-link'>consequuntur recusandae? Ipsam</a>, asperiores.</p>
<p>Expedita saepe illo vero sit et! Eveniet, deserunt. Nihil omnis fugit ut veniam ullam, non maiores, consequatur amet enim dolore totam, laborum accusantium voluptatum iure est ab aspernatur reiciendis explicabo.</p>
<p>Eos reprehenderit suscipit, at eveniet, minus ea quod quis provident, nisi fugit maiores molestias culpa. Rerum rem pariatur quo mollitia autem omnis eum officiis, natus beatae eos saepe culpa earum!</p>
<p>Eum ut tempore delectus quos unde tenetur neque perspiciatis. Dicta sunt rem dolore, in ab impedit assumenda, quaerat neque quos veritatis consequatur accusantium dignissimos eius natus iusto nostrum eos maxime.</p>
</div>
</div>
</div>
</section>
$nav-depth: 77px;
// button sizes used in calculation for right hand nav spacings
$small-button-size: 2.81rem;
$standard-button-size: 3.12rem;
@keyframes info-text-fade {
0% {
display: none;
opacity: 0;
}
1% {
display: block;
opacity: 0;
// transform: scale(0);
}
30% {
display: block;
opacity: 0;
// transform: scale(0);
}
100% {
display: block;
opacity: 1;
// transform: scale(1);
}
}
.uol-gallery-modal {
position: absolute;
width: 100vw;
height: calc(var(--vh, 1vh) * 99.9); // 99.9% to avoid distortion when zoomed in
left: 0;
top: 0;
background: $color-black--dark;
z-index: 10;
color: $color-white;
}
.uol-gallery-modal__button-close {
.js .uol-gallery-modal & {
position: absolute;
top: $spacing-4;
right: $spacing-3;
z-index: 2;
@media (orientation: portrait) {
@include media(">=uol-media-xs") {
right: $spacing-5;
}
}
@media (orientation: landscape) {
top: $spacing-2;
right: ($nav-depth / 2);
transform: translateX(50%);
@include media(">=uol-media-s") {
top: $spacing-5;
}
}
svg {
path {
fill: $color-white;
@media (-ms-high-contrast: active), (forced-colors: active) {
fill: ButtonText;
}
}
}
}
}
.uol-gallery-modal__track {
margin: 0;
padding: 0;
height: calc(var(--vh, 1vh) * 99.9);
width: 100vw;
list-style: none;
scroll-snap-type: x mandatory;
display: flex;
flex-wrap: wrap;
flex-direction: column;
overflow-x: auto;
&::-webkit-scrollbar {
display: none;
}
// TODO: IE 11 remove scrollbar
-ms-overflow-style: none;
}
.uol-gallery-modal__track--smooth {
scroll-behavior: smooth;
}
.uol-gallery-modal__item {
position: relative;
box-sizing: border-box;
display: flex;
width: 100vw;
height: 100%;
scroll-snap-align: start;
background: $color-black;
@media (orientation: portrait) {
margin-top: $nav-depth;
height: calc(100% - #{$nav-depth});
}
}
.uol-gallery-modal__info-container {
box-sizing: border-box;
background: rgba($color-black--dark, 0.92);
z-index: 1;
transition: all 0.3s ease;
@media (orientation: landscape) {
position: absolute;
width: $nav-depth;
height: 100%;
border-right: 3px solid $color-border--light;
display: flex;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: calc(#{$nav-depth} - 3px);
background-color: $color-black--dark;
}
}
@media (orientation: portrait) {
position: absolute;
bottom: 0;
width: 100%;
max-height: 100%;
overflow-y: hidden;
border-top: 3px solid $color-border--light;
padding: $spacing-4 $spacing-5;
}
}
.uol-gallery-modal__info-container--open {
@media (orientation: portrait) {
padding-bottom: $spacing-6;
}
@media (orientation: landscape) {
width: calc(100vw - (#{$nav-depth} * 1.5));
@include media(">=uol-media-s") {
width: calc(80vw - (#{$nav-depth}));
}
@include media(">=uol-media-m") {
width: calc(70vw - (#{$nav-depth}));
}
@include media(">=uol-media-l") {
width: calc(60vw - (#{$nav-depth}));
}
@include media(">=uol-media-xl") {
width: calc(50vw - (#{$nav-depth}));
}
}
}
.uol-gallery-modal__button-info {
@media (orientation: landscape) {
position: absolute;
left: $spacing-4;
top: $spacing-2;
@include media(">=uol-media-s") {
top: $spacing-5;
}
@include media(">=uol-media-l") {
left: $spacing-3;
}
}
.uol-gallery-modal__info-container--open & {
border: 2px solid $color-white;
}
}
.uol-gallery-modal__info {
@include ds-scrollbars();
display: none;
box-sizing: border-box;
overflow-y: auto;
@media (orientation: portrait) {
margin-top: $spacing-4;
max-height: calc(var(--vh, 1vh) * 100 - (#{$nav-depth} * 2) - #{$spacing-6});
padding-right: $spacing-4;
}
@media (orientation: landscape) {
box-sizing: border-box;
flex-basis: calc(100% - #{$nav-depth});
margin: $spacing-5 $spacing-2 0 auto;
padding-right: $spacing-2;
padding-left: $spacing-4;
}
.uol-gallery-modal__button-info[aria-expanded="true"] + & {
display: block;
@media (orientation: landscape) {
animation: info-text-fade 0.7s ease;
animation-fill-mode: both;
}
}
}
.uol-gallery-modal__info__title {
@extend .uol-typography-heading-2;
margin-top: 0;
margin-bottom: $spacing-4;
@media (orientation: landscape) {
margin-top: $spacing-4;
@include media(">=uol-media-m") {
margin-top: $spacing-2;
}
}
}
.uol-gallery-modal__figure {
position: relative;
display: flex;
flex-wrap: wrap;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
@media (orientation: landscape) {
height: 100%;
width: calc(100% - #{$nav-depth});
.uol-gallery-modal__info-container + & {
margin-left: $nav-depth;
width: calc(100% - (#{$nav-depth} * 2));
}
}
@media (orientation: portrait) {
.uol-gallery-modal__item--has-info & {
height: calc(100% - #{$nav-depth} - 3px);
}
}
}
.uol-gallery-modal__image-container {
position: absolute;
top: 50%;
height: 100%;
right: 0;
bottom: 0;
left: 0;
transform: translateY(-50%);
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
}
.uol-gallery-modal__image-container--video {
top: auto;
transform: none;
position: relative;
aspect-ratio: 16/9;
overflow: hidden;
iframe {
border: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
}
.uol-gallery-modal__image-caption {
@extend %text-size-caption;
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: $spacing-2 $spacing-4;
background: rgba($color-black, 0.88);
color: $color-white;
// TODO: This is a hack to stop caption overlapping video controls
@media (orientation: landscape) {
.uol-gallery-modal__figure--video & {
display: none;
}
}
}
.uol-gallery-modal__nav-container {
box-sizing: border-box;
position: fixed;
background: $color-black--dark;
display: flex;
z-index: 1;
@media (orientation: landscape) {
top: 0;
right: 0;
width: $nav-depth;
flex-direction: column;
height: 100%;
padding: $small-button-size + $spacing-2 $spacing-3 0;
@include media(">=uol-media-s") {
padding: $small-button-size + $spacing-5 $spacing-3 0;
}
@include media(">=uol-media-l") {
padding: $standard-button-size + $spacing-5 $spacing-3 0;
}
button {
margin-bottom: $spacing-2;
margin-left: auto;
margin-right: auto;
@include media(">=uol-media-s") {
margin-bottom: $spacing-5;
padding: $spacing-9 $spacing-3 $spacing-4;
}
}
}
@media (orientation: portrait) {
top: 0;
height: $nav-depth;
width: 100%;
justify-content: center;
align-items: center;
}
}
.uol-gallery-modal__progress {
font-variant-numeric: lining-nums;
@media (orientation: landscape) {
order: -1;
text-align: center;
margin: $spacing-2 0;
@include media(">=uol-media-s") {
margin: $spacing-5 0;
}
}
@media (orientation: portrait) {
margin: 0 $spacing-5;
@include media(">=uol-media-xs") {
margin: 0 $spacing-7;
}
@include media(">=uol-media-s") {
margin: 0 $spacing-8;
}
}
}
.uol-gallery-modal__progress__current,
.uol-gallery-modal__progress__total {
position: relative;
top: 0.05em;
}
.uol-gallery-modal__progress__current {
color: $color-brand--bright;
}
@mixin galleryItemHalf {
flex-basis: calc(50% - #{$spacing-4});
margin: 0 $spacing-2 $spacing-4;
@include media(">=uol-media-l") {
flex-basis: calc(50% - #{$spacing-5});
margin: 0 $spacing-3 $spacing-4;
}
@include media(">=uol-media-xl") {
flex-basis: calc(50% - #{$spacing-6});
margin: 0 $spacing-4 $spacing-5;
}
}
// Keyframe animations
@keyframes skeletonBg {
0% {
background-color: $color-grey--light;
}
50% {
background-color: rgba($color-grey--light, 0.2);
}
100% {
background-color: $color-grey--light;
}
}
@keyframes galleryImageFadeIn {
0% {
display: none;
opacity: 0;
}
1% {
display: block;
}
100% {
display: block;
opacity: 1;
}
}
.uol-gallery-container {
width: 100%;
overflow-x: hidden;
}
.uol-gallery {
display: flex;
flex-wrap: wrap;
margin-left: -#{$spacing-2};
margin-right: -#{$spacing-2};
@include media(">=uol-media-l") {
margin-left: -#{$spacing-3};
margin-right: -#{$spacing-3};
}
@include media(">=uol-media-xl") {
margin-left: -#{$spacing-4};
margin-right: -#{$spacing-4};
}
.uol-rich-text & {
max-width: none;
}
}
.uol-gallery__item {
box-sizing: border-box;
.js & {
@include galleryItemHalf;
}
&:first-of-type {
flex-basis: 100%;
}
.uol-gallery--count-2 &,
.uol-gallery--count-4 & {
&:first-of-type {
@include galleryItemHalf;
}
}
// Hide all after 5
&:nth-of-type(5) ~ & {
.js & {
display: none;
}
}
}
.uol-gallery__item__title {
.js & {
@extend .hide-accessible;
}
}
.uol-gallery__figure {
.uol-gallery__item--video & {
display: none;
.js & {
display: block;
}
}
.uol-rich-text & {
margin: 0;
}
}
.uol-gallery__image-caption {
@extend %text-size-caption;
color: $color-font--light;
padding-top: $spacing-2;
@include media(">=uol-media-l") {
padding-top: $spacing-3;
}
}
.uol-gallery__image-container {
font-size: 0;
@include imageFit(66.6%);
.uol-gallery__item:first-of-type & {
@include imageFit(50%);
}
.uol-gallery--count-2 .uol-gallery__item:nth-of-type(2) & {
@include imageFit(50%);
}
.uol-gallery--count-4 .uol-gallery__item:first-of-type & {
@include imageFit(66.6%);
}
.uol-gallery__item--video & {
outline: 1px solid rgba($color-grey--dark, 0.7);
outline-offset: -1px;
background-color: $color-grey--light;
animation-name: skeletonBg;
animation-duration: 2.49s;
animation-iteration-count: 2;
img {
opacity: 0;
}
}
.uol-gallery__item--video[aria-busy=false] & {
outline: none;
img {
opacity: 0;
display: block;
animation: galleryImageFadeIn 1s ease-out forwards;
}
}
@for $i from 1 through 5 {
.uol-gallery__item--video:nth-of-type(5n + #{$i}) & {
animation-delay: #{$i * 0.2}s;
}
}
}
.uol-gallery__item__content {
.js & {
display: none;
}
}
.uol-gallery__button {
background: rgba($color-black, 0.75);
border: none;
}
.uol-gallery__button--open-item {
@include button_focus(-3px, false, $color-brand--bright);
position: absolute;
width: 45px;
height: 45px;
border-radius: 50%;
left: $spacing-2;
bottom: $spacing-2;
transition: all 0.15s;
&:hover,
&:focus {
background: $color-black;
color: $color-brand--bright;
}
@include media(">=uol-media-m") {
left: $spacing-5;
bottom: $spacing-5;
}
// Override the default uol-icon positioning
.js .uol-gallery__item & {
position: absolute;
}
}
.uol-gallery__button--with-count {
@include button_focus(-2px, false, $color-brand--bright);
@extend %text-size-heading-2;
position: absolute;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba($color-black, 0.65);
color: $color-white;
z-index: 2;
text-decoration-color: $color-brand--bright;
transition: all 0.15s;
&:hover,
&:focus {
background: rgba($color-black, 0.75);
text-decoration: underline;
text-decoration-color: $color-brand--bright;
@media (-ms-high-contrast: active), (forced-colors: active) {
text-decoration-color: ButtonText;
border: 2px solid ButtonText;
}
}
}
// Update for video variant using play icon
.js .uol-gallery__item--video-play-icon
.uol-button.uol-icon--icon-only {
@extend %text-size-heading-2;
background: $color-brand;
border: 2px solid $color-white;
border-radius: 50%;
color: $color-white;
aspect-ratio: 1/1;
width: $spacing-8;
height: $spacing-8;
left: $spacing-5;
bottom: $spacing-5;
svg {
width: $spacing-7;
height: $spacing-7;
}
}
// TODO: IE11 resizeObserver polyfill
import ResizeObserver from "resize-observer-polyfill";
/*
* Set shift keydown listener
* Needed to overcome tab oder/focus issues when reverse tabbing.
* See listenGalleryScroll()
*/
let shiftKeyDown = false;
function isShiftKeyDown(event) {
shiftKeyDown = event.shiftKey;
}
document.addEventListener("keydown", isShiftKeyDown);
document.addEventListener("keyup", isShiftKeyDown);
/**
* Returns the modal outer to contain the new gallery
* @returns {element} Modal element
*/
const modalOuter = () => {
const modal = document.createElement("div");
modal.setAttribute("role", "dialog");
modal.setAttribute("tabindex", "-1");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-label", "Gallery carousel");
modal.classList.add("uol-gallery-modal");
return modal;
};
/**
* HTML fragment for close button
*/
const buttonClose = `
<button class="uol-button uol-icon uol-icon--icon-only uol-icon--mdiClose uol-gallery-modal__button-close" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" focusable="false" aria-hidden="true">
<path fill="#000000" fill-rule="nonzero" d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"></path>
</svg>
<span class="uol-icon__label">Close</span>
</button>
`;
/**
* Create the modal gallery navigation.
* Returns '' if only 1 item and HTML string otherwise
* @param {object} items - NodeList of the gallery's initial items
* @param {number} index - the index of the gallery item to open on
* @returns {string} HTML for modal navigation
*/
const galleryNavigation = (items, index) => {
// if there is only one item we do not need navigation
if (items.length === 1) return ''
// Otherwise return navigation
return `
<div class="uol-gallery-modal__nav-container" aria-hidden="true">
<button
tabindex="-1"
class="
uol-gallery-modal__button-nav
uol-gallery-modal__button-nav--prev
uol-button uol-button--bright uol-icon uol-icon--icon-only uol-icon--mdiArrowLeft"
type="button" ${index === 0 ? "disabled" : ""}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" focusable="false" aria-hidden="true">
<path fill="#000000" fill-rule="nonzero" d="M20,11V13H8L13.5,18.5L12.08,19.92L4.16,12L12.08,4.08L13.5,5.5L8,11H20Z"></path>
</svg>
<span class="uol-icon__label">Previous</span>
</button>
<span class="uol-gallery-modal__progress">
<span class="uol-gallery-modal__progress__current">${index + 1}</span> /
<span class="uol-gallery-modal__progress__total">${items.length}</span>
</span>
<button tabindex="-1" class="uol-gallery-modal__button-nav uol-gallery-modal__button-nav--next uol-button uol-button--bright uol-icon uol-icon--icon-only uol-icon--mdiArrowRight" type="button" ${
index === items.length - 1 ? "disabled" : ""
}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" focusable="false" aria-hidden="true">
<path fill="#000000" fill-rule="nonzero" d="M4,11V13H16L10.5,18.5L11.92,19.92L19.84,12L11.92,4.08L10.5,5.5L16,11H4Z"></path>
</svg>
<span class="uol-icon__label">Next</span>
</button>
</div>
`;
};
/**
* Create the list of gallery items
* @param {Object} items - NodeList of the gallery's initial items
* @returns {string} - HTML fragment for gallery items list
*/
const galleryItems = (items) => {
return `
<ol class="uol-gallery-modal__track">
${[...items]
.map((item, idx) => {
return galleryItem(item, idx, items.length);
})
.join("")}
</ol>
`;
};
/**
* Create the gallery item for inclusion in the list
* @param {object} item - item from NodeList of items
* @param {number} idx - the index of this item in the Items object
* @returns {string} - HTML fragment for gallery item
*/
const galleryItem = (item, idx, itemsLength) => {
const hasInfo = item.querySelector(".uol-gallery__item__content");
return `
<li class="uol-gallery-modal__item ${
hasInfo ? "uol-gallery-modal__item--has-info" : ""
}" tabindex="0" aria-label="Item ${idx + 1} of ${itemsLength}">
${galleryItemInfo(item, idx)}
${galleryItemFigure(item, idx)}
</li>
`;
};
/**
* Create the gallery item info for inclusion in the gallery item
* @param {object} item - item from NodeList of items
* @param {number} idx - the index of this item in the Items object
* @returns {string} - HTML fragment for gallery item info
*/
const galleryItemInfo = (item, idx) => {
// Get item title
const itemTitle = item.querySelector(".uol-gallery__item__title");
const itemTitleText = itemTitle ? itemTitle.innerText : null;
// Get item text content
const itemContent = item.querySelector(".uol-gallery__item__content");
const itemContentHTML = itemContent ? itemContent.innerHTML : "";
// If no info content return empty string
if (!itemContentHTML) return "";
// Otherwise return the info fragment
return `
<div class="uol-gallery-modal__info-container">
<button aria-expanded="false" class="uol-gallery-modal__button-info uol-button uol-button--bright uol-icon uol-icon--icon-only uol-icon--icon-only--large uol-icon--mdiInformationVariant" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" focusable="false" aria-hidden="true">
<path fill="#000000" fill-rule="nonzero" d="M13.5,4A1.5,1.5 0 0,0 12,5.5A1.5,1.5 0 0,0 13.5,7A1.5,1.5 0 0,0 15,5.5A1.5,1.5 0 0,0 13.5,4M13.14,8.77C11.95,8.87 8.7,11.46 8.7,11.46C8.5,11.61 8.56,11.6 8.72,11.88C8.88,12.15 8.86,12.17 9.05,12.04C9.25,11.91 9.58,11.7 10.13,11.36C12.25,10 10.47,13.14 9.56,18.43C9.2,21.05 11.56,19.7 12.17,19.3C12.77,18.91 14.38,17.8 14.54,17.69C14.76,17.54 14.6,17.42 14.43,17.17C14.31,17 14.19,17.12 14.19,17.12C13.54,17.55 12.35,18.45 12.19,17.88C12,17.31 13.22,13.4 13.89,10.71C14,10.07 14.3,8.67 13.14,8.77Z"></path>
</svg>
<span class="uol-icon__label">Show information</span>
</button>
<aside class="uol-gallery-modal__info" tabindex="0" aria-labelledby="item-title-${idx}">
${
itemTitle
? `<h2 id="item-title-${idx}" class="uol-gallery-modal__info__title">${itemTitleText}</h2>`
: ""
}
${itemContentHTML}
</aside>
</div>
`;
};
/**
* Create the <figure> element the the gallery item
* @param {object} item - item from NodeList of items
* @param {number} idx - the index of this item in the Items object
* @returns {string} - HTML fragment for gallery item figure element
*/
const galleryItemFigure = (item, idx) => {
// Get image and attributes
const itemImage = item.querySelector(".uol-gallery__image-container img");
const imgAlt = itemImage ? itemImage.getAttribute("alt") : "";
// Set image src to either image data attribute or native src
let imgSrc
if ( itemImage.dataset.srcHighQuality ) {
imgSrc = itemImage.dataset.srcHighQuality;
} else if (itemImage.getAttribute("src")) {
imgSrc = itemImage.getAttribute("src");
}
// Set image caption if present in item
const caption = item.querySelector(".uol-gallery__image-caption");
const captionText = caption ? caption.innerText : null;
// Set YouTube constants
const youtubeUrl = item.dataset.youtubeUrl;
const youtubeTitle = item.dataset.youtubeTitle;
const youtubeId = item.dataset.youtubeId;
let figureContent;
if (youtubeUrl) {
// As the YouTube iframe breaks the focus trap
// we include a visually hidden text link so that
// there is a focusable element after the iframe
figureContent = `
<iframe
class="youtube"
tabindex="0"
id="video-${idx}"
data-youtube-id="${youtubeId}"
title="YouTube: ${youtubeTitle}"
src="${youtubeUrl}"
allow="autoplay"
loading="lazy"></iframe>
<a class="uol-gallery-modal__embed-link" href="${youtubeUrl}" target="_blank" rel="noopener">Open <span class="hide-accessible">${youtubeTitle}</span> in new window</a>`;
} else if (itemImage) {
figureContent = `<img src="${imgSrc}" alt="${imgAlt}" loading="lazy" />`;
}
return `
<figure class="uol-gallery-modal__figure${
youtubeUrl ? " uol-gallery-modal__figure--video" : ""
}">
<div class="uol-gallery-modal__image-container ${
youtubeUrl ? "uol-gallery-modal__image-container--video" : ""
}">
${figureContent}
</div>
${
captionText
? `<figcaption class="uol-gallery-modal__image-caption">${captionText}</figcaption>`
: ""
}
</figure>
`;
};
/**
* "Close gallery" tasks
* @param {object} modal - The current modal gallery
* @param {object} focusedElementBeforeModal - HTML node that triggered the gallery open
*/
const closeGalleryModal = (modal, focusedElementBeforeModal) => {
galleryPageContent.hidden = false;
modal.remove();
focusedElementBeforeModal.focus();
focusedElementBeforeModal.parentElement.scrollIntoView();
};
/**
* Container function for listeners
* @param {object} modal - The current modal gallery
* @param {object} focusedElementBeforeModal - HTML node that triggered the gallery open
*/
const addListeners = (modal, focusedElementBeforeModal) => {
const closeButton = modal.querySelector(".uol-gallery-modal__button-close");
// Listen for close actions
listenCloseActions(modal, focusedElementBeforeModal, closeButton);
// Listen for tab keys
listenTabKey(modal, closeButton);
// Listen for button navigation
listenNavButtons(modal);
// Listen for swipe and scroll navigation
listenGalleryScroll(modal);
// Listen to info buttons clicks
listenInfoButtons(modal);
listenOrientationChange(modal);
};
/**
* Listen for close actions. ie Close button press or escape key
* @param {object} modal
* @param {object} focusedElementBeforeModal
* @param {object} closeButton
*/
const listenCloseActions = (modal, focusedElementBeforeModal, closeButton) => {
closeButton.onclick = () => {
closeGalleryModal(modal, focusedElementBeforeModal);
};
// Handle escape key
modal.addEventListener("keydown", (event) => {
if (event.keyCode === 27) {
closeGalleryModal(modal, focusedElementBeforeModal);
}
});
};
/**
* Listen for Tab key and ensure modal focus trap
*/
const listenTabKey = (modal, closeButton) => {
const modelItems = modal.querySelectorAll(".uol-gallery-modal__item");
modal.addEventListener("keydown", (event) => {
// Handle TAB key to produce a modal trap
if (event.keyCode === 9) {
// Find all focusable modal elements
let focusableElements = modal.querySelectorAll(
".uol-gallery-modal__item, .uol-gallery-modal__track button:not([disabled]), .uol-gallery-modal__track a"
);
// We're only interested in visible elements so we create a new array containing only those elements where the offsetWidth !== 0
let visibleFocusableElements = [];
focusableElements.forEach((element) => {
if (element.offsetWidth !== 0) {
visibleFocusableElements.push(element);
}
});
// Define the last focusable element
const lastElementOfModal =
visibleFocusableElements[visibleFocusableElements.length - 1];
// If last item in gallery carousel
if (document.activeElement === lastElementOfModal) {
event.preventDefault();
closeButton.focus();
}
// Shift + Tab on close button - return to last slide
if (event.shiftKey && document.activeElement === closeButton) {
event.preventDefault();
modelItems[modelItems.length - 1].focus();
}
}
});
};
/**
* Listen to navigation buttons and scroll the gallery track to the new item
* @param {Element} modal
*/
const listenNavButtons = (modal) => {
const navButtons = modal.querySelectorAll(".uol-gallery-modal__button-nav");
const navButtonPrev = modal.querySelector(
".uol-gallery-modal__button-nav--prev"
);
const navButtonNext = modal.querySelector(
".uol-gallery-modal__button-nav--next"
);
const modalTrack = modal.querySelector(".uol-gallery-modal__track");
const modelItems = modalTrack.querySelectorAll(".uol-gallery-modal__item");
const counter = modal.querySelector(".uol-gallery-modal__progress__current");
navButtons.forEach((navButton) => {
navButton.onclick = () => {
let current = parseInt(counter.innerText) - 1;
if (navButton == navButtonPrev) {
current--;
} else if (navButton == navButtonNext) {
current++;
}
// Scroll to item
modalTrack.scrollLeft = modelItems[current].offsetLeft;
// Update counter
counter.innerText = current + 1;
if (current === 0) {
navButtonPrev.disabled = true;
navButtonNext.disabled = false;
} else if (current === modelItems.length - 1) {
navButtonNext.disabled = true;
navButtonPrev.disabled = false;
} else {
navButtonNext.disabled = false;
navButtonPrev.disabled = false;
}
};
});
};
/**
* Listen for modal track scroll and:
* - Update current item display
* - Update previous and next buttons
* - Pause offscreen YouTube videos
* @param {Element} modal
*/
const listenGalleryScroll = (modal) => {
// TODO: Add all listeners to wrapper function and declare these all once
const modalTrack = modal.querySelector(".uol-gallery-modal__track");
const modelItems = modalTrack.querySelectorAll(".uol-gallery-modal__item");
const counter = modal.querySelector(".uol-gallery-modal__progress__current");
const navButtonPrev = modal.querySelector(
".uol-gallery-modal__button-nav--prev"
);
const navButtonNext = modal.querySelector(
".uol-gallery-modal__button-nav--next"
);
let isScrolling = null;
modalTrack.addEventListener("scroll", () => {
window.clearTimeout(isScrolling);
isScrolling = setTimeout(() => {
modelItems.forEach((item, itemIndex) => {
let itemLeft = item.getBoundingClientRect().x;
if (!itemLeft)
// Handle IE11
itemLeft = item.getBoundingClientRect().left;
// If item is more than half in view
if (itemLeft >= -10 && itemLeft < modalTrack.clientWidth / 2) {
// Update counter text
counter.innerText = itemIndex + 1;
/*
* Focus on current item unless the shift key is down
* to overcome tab oder/focus issues when reverse tabbing.
*/
if (!shiftKeyDown) {
item.focus();
}
// Update buttons on scroll
if (itemIndex === 0) {
navButtonPrev.disabled = true;
navButtonNext.disabled = false;
} else if (itemIndex === modelItems.length - 1) {
navButtonNext.disabled = true;
navButtonPrev.disabled = false;
} else {
navButtonNext.disabled = false;
navButtonPrev.disabled = false;
}
// Pause all youtube videos
pauseYouTubeVideos(modal);
}
});
}, 100);
});
};
const pauseYouTubeVideos = (modal) => {
const videoIframes = modal.querySelectorAll('iframe')
videoIframes.forEach((iframe) => {
// Check if loaded after lazy by seeing if postMessage is available
const iframeDoc = iframe.contentWindow;
// Only post message if iframe accepts postMessage
// NB: Relies on undocumented YouTube postMessage API.
// If this stops working we will need to replace with the YouTube iframe API
if ( iframeDoc.postMessage ) {
iframe.contentWindow.postMessage(
JSON.stringify({ event: "command", func: "pauseVideo" }),
"https://www.youtube-nocookie.com"
);
}
});
}
const listenInfoButtons = (modal) => {
const infoButtons = modal.querySelectorAll(".uol-gallery-modal__button-info");
infoButtons.forEach((infoButton) => {
const infoParent = infoButton.closest(".uol-gallery-modal__info-container");
infoButton.onclick = () => {
let expanded =
infoButton.getAttribute("aria-expanded") === "true" || false;
infoButton.setAttribute("aria-expanded", !expanded);
if (!expanded) {
infoParent.classList.add("uol-gallery-modal__info-container--open");
} else {
infoParent.classList.remove("uol-gallery-modal__info-container--open");
}
};
});
};
/**
* Listen for orientation changes and move the info container
* to ensure correct tab order
* @param {*} modal
*/
const listenOrientationChange = modal => {
// Get the gallery items
const items = modal.querySelectorAll(".uol-gallery-modal__item");
// Create the query list.
const mediaQueryList = window.matchMedia("(orientation: portrait)");
// Define a callback function for the event listener.
function handleOrientationChange(mql) {
items.forEach((item) => {
// Get the info container
const itemInfo = item.querySelector(
".uol-gallery-modal__info-container"
);
// If item contains info container
if (itemInfo) {
// if portrait orientation
if (mql.matches) {
item.insertAdjacentElement("beforeend", itemInfo);
} else {
item.insertAdjacentElement("afterbegin", itemInfo);
}
}
});
}
// Run the orientation change handler once.
handleOrientationChange(mediaQueryList);
// Add the callback function as a listener to the query list.
// TODO: IE11 support - revert to addEventListener when we drop is
// mediaQueryList.addEventListener("change", handleOrientationChange);
mediaQueryList.addListener(handleOrientationChange);
}
const galleryPageContent = document.querySelector(".site-outer");
/**
* Add open modal buttons to each item in initial gallery.
* @param {NodeList} items
*/
const galleryAddButtons = (items) => {
items.forEach((item, index) => {
const button = document.createElement("button");
button.classList.add("uol-gallery__button");
// Set basic buttonText
let buttonText = `Open gallery at item ${ index + 1 } of ${items.length}`
// Enhance buttonText
// If item has title append title
if (item.querySelector(".uol-gallery__item__title")) {
buttonText +=
": " + item.querySelector(".uol-gallery__item__title").innerHTML;
}
// If item does not have title and is type video append ": Video"
else if (item.classList.includes("uol-gallery__item--video")) {
buttonText += ": Video";
}
// If item does not have title and is type image append ": Image"
else if (item.classList.includes("uol-gallery__item--image")) {
buttonText += ": Image";
}
button.innerHTML = `<span class="hide-accessible">${buttonText}</span>`;
if (index === 4 && items.length > 5) {
button.classList.add("uol-gallery__button--with-count");
button.innerHTML =
button.innerHTML +
`<span aria-hidden="true">+${items.length - (index + 1)}</span>`;
} else {
button.classList.add("uol-button");
button.classList.add("uol-icon");
button.classList.add("uol-icon--icon-only");
button.classList.add("uol-gallery__button--open-item");
// Check if 'uol-gallery__item--video-play-icon' exists on video item (this can be passed through config)
const isVideoPlayIcon = item.classList.contains('uol-gallery__item--video-play-icon');
isVideoPlayIcon ? button.classList.add("uol-icon--mdiPlay") : button.classList.add("uol-icon--mdiArrowExpand");
}
button.onclick = (event) => {
openGalleryModal(items, index, event.target);
};
item.querySelector(".uol-gallery__image-container").appendChild(button);
});
};
/*
* youtube_parser
* Get YouTube IDs from URLs
*/
const youtube_parser = (url) => {
var regExp =
/^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/;
var match = url.match(regExp);
return match && match[7].length == 11 ? match[7] : false;
};
/**
* Get images for gallery items from YouTube
* @param {NodeList} items
*/
const galleryVideoImages = (items) => {
// TODO: Add full oEmbed support
items.forEach((item) => {
const videoUrl = item.dataset.video;
if (videoUrl) {
const youtubeId = youtube_parser(videoUrl);
const itemImg = item.querySelector("img");
itemImg.hidden = true;
item.setAttribute("aria-busy", true )
itemImg.onload = () => {
item.setAttribute("aria-busy", false);
itemImg.hidden = false;
};
if (youtubeId) {
itemImg.src =
"https://i.ytimg.com/vi/" + youtubeId + "/maxresdefault.jpg";
fetch(
"https://www.youtube.com/oembed?url=http%3A//www.youtube.com/watch?v%3D" +
youtubeId +
"&format=json",
{
method: "get",
}
)
.then((response) => response.json())
.then((data) => {
const youtubeSrc = data.html.match(
/\<iframe.+src\=(?:\"|\')(.+?)(?:\"|\')(?:.+?)\>/
);
const youtubeURL = youtubeSrc[1].replace(
"youtube.com",
"youtube-nocookie.com"
);
item.dataset.youtubeUrl =
youtubeURL + "&rel=0&enablejsapi=1";
item.dataset.youtubeTitle = data.title;
item.dataset.youtubeId = youtubeId;
});
}
}
});
};
/**
* Creates the gallery modal on button click
* @param {NodeList} items - NodeList of the gallery's initial items
* @param {number} index - the index of the gallery item to open on
* @param {Node} focusedElementBeforeModal - HTML node that triggered the gallery open
*/
const openGalleryModal = (items, index, focusedElementBeforeModal) => {
// Create modal
const modal = modalOuter();
// Add close button to modal
modal.innerHTML += buttonClose;
// Add gallery items to modal
modal.innerHTML += galleryItems(items);
// Add navigation to modal
modal.innerHTML += galleryNavigation(items, index);
// Add listeners
addListeners(modal, focusedElementBeforeModal);
// Add modal to page
document.body.appendChild(modal);
// Scroll to selected item
const modelItems = modal.querySelectorAll(".uol-gallery-modal__item");
// Delay scroll to work around iOS Safari rendering issues
setTimeout(() => {
modelItems[index].focus();
modelItems[index].scrollIntoView();
// Add smooth scroll class for future interactions
const modalTrack = modal.querySelector(".uol-gallery-modal__track");
modalTrack.classList.add("uol-gallery-modal__track--smooth");
}, 10);
// Resize iframes to keep aspect ratio
const videoFigures = modal.querySelectorAll(
".uol-gallery-modal__figure--video"
);
videoFigures.forEach( videoFigure => {
const container = videoFigure;
const object = videoFigure.querySelector(
".uol-gallery-modal__image-container--video"
);
const aspectRatio = 16 / 9;
function update() {
const isTall =
container.clientWidth / container.clientHeight < aspectRatio;
// if IE 11 TODO: IE11 hack
if (window.msCrypto) {
object.style.width = "100%";
object.style.height = "100%";
} else {
object.style.width = isTall ? "100%" : "auto";
object.style.height = isTall ? "auto" : "100%";
}
}
new ResizeObserver(update).observe(container);
})
// Hide other page content
if (galleryPageContent) {
galleryPageContent.hidden = true;
}
};
export const uolGallery = () => {
const galleries = document.querySelectorAll(".uol-gallery");
galleries.forEach((gallery) => {
const items = gallery.querySelectorAll(".uol-gallery__item");
galleryVideoImages(items);
galleryAddButtons(items);
});
};
{
"gallery": {
"headingLevel": 2,
"items": [
{
"title": "“Dual Form” by Barbara Hepworth",
"img": {
"srcHighQuality": "/placeholders/campus/full/29940.jpeg",
"src": "/placeholders/campus/medium/29940.jpeg",
"alt": "Dual Form sculpture by Barbara Hepworth with people relaxing on grass in background",
"caption": "“Dual Form” by Barbara Hepworth"
},
"content": "<p>Lorem ipsum <a href='/some-text-link'>dolor sit amet consectetur</a> adipisicing elit. Corrupti, quasi nostrum blanditiis hic totam a id architecto molestias, sunt vitae iste consectetur cupiditate incidunt autem illo, consequuntur recusandae? Ipsam, asperiores.</p><p>Expedita saepe illo vero sit et! Eveniet, deserunt. Nihil omnis fugit ut veniam ullam, non maiores, consequatur amet enim dolore totam, laborum accusantium voluptatum iure est ab aspernatur reiciendis explicabo.</p><p>Eos reprehenderit suscipit, at eveniet, minus ea quod quis provident, nisi fugit maiores molestias culpa. Rerum rem pariatur quo mollitia autem omnis eum officiis, natus beatae eos saepe culpa earum!</p><p>Eum ut tempore delectus quos unde tenetur neque perspiciatis. Dicta sunt rem dolore, in ab impedit assumenda, quaerat neque quos veritatis consequatur accusantium dignissimos eius natus iusto nostrum eos maxime.</p>"
},
{
"title": "Edward Boyle Library",
"img": {
"srcHighQuality": "/placeholders/campus/full/28573.jpeg",
"src": "/placeholders/campus/medium/28573.jpeg",
"alt": "Steps outside Edward Boyle Library"
},
"content": "<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Corrupti, quasi nostrum blanditiis hic totam a id architecto molestias, sunt vitae iste consectetur cupiditate incidunt autem illo, consequuntur recusandae? Ipsam, asperiores.</p><p>Expedita saepe illo vero sit et! Eveniet, deserunt. Nihil omnis fugit ut veniam ullam, non maiores, consequatur amet enim dolore totam, laborum accusantium voluptatum iure est ab aspernatur reiciendis explicabo.</p><p>Eos reprehenderit suscipit, at eveniet, <a href='/some-text-link'>minus ea quod</a> quis provident, nisi fugit maiores molestias culpa. Rerum rem pariatur quo mollitia autem omnis eum officiis, natus beatae eos saepe culpa earum!</p><p>Eum ut tempore delectus quos unde tenetur neque perspiciatis. Dicta sunt rem dolore, in ab impedit assumenda, quaerat neque quos veritatis consequatur accusantium dignissimos eius natus iusto nostrum eos maxime.</p>"
},
{
"title": "Victoria Quarter Arcade, Leeds",
"img": {
"srcHighQuality": "/placeholders/campus/full/29936.jpeg",
"src": "/placeholders/campus/medium/29936.jpeg",
"alt": "Interior of Victoria Quarter Arcade",
"caption": "Victoria Quarter"
},
"content": "<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Corrupti, quasi nostrum blanditiis hic totam a id architecto molestias, sunt vitae iste consectetur cupiditate incidunt autem illo, <a href='/some-text-link'>consequuntur recusandae? Ipsam</a>, asperiores.</p><p>Expedita saepe illo vero sit et! Eveniet, deserunt. Nihil omnis fugit ut veniam ullam, non maiores, consequatur amet enim dolore totam, laborum accusantium voluptatum iure est ab aspernatur reiciendis explicabo.</p><p>Eos reprehenderit suscipit, at eveniet, minus ea quod quis provident, nisi fugit maiores molestias culpa. Rerum rem pariatur quo mollitia autem omnis eum officiis, natus beatae eos saepe culpa earum!</p><p>Eum ut tempore delectus quos unde tenetur neque perspiciatis. Dicta sunt rem dolore, in ab impedit assumenda, quaerat neque quos veritatis consequatur accusantium dignissimos eius natus iusto nostrum eos maxime.</p>"
}
]
}
}