Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 114 additions & 31 deletions libs/c2/blocks/logo-ticker/logo-ticker.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,29 @@
--logo-ticker-gap: var(--s2a-spacing-80);
--logo-ticker-logo-height: 36px;
--logo-ticker-dir: 1;
--logo-ticker-drift-distance: 240px;
--logo-ticker-duration: 30s;
--logo-ticker-inset: var(--s2a-spacing-24);
--logo-ticker-btn-size: 2rem;
--logo-ticker-mask-width: calc(var(--logo-ticker-inset) * 2 + var(--logo-ticker-btn-size));

position: relative;
width: 100%;
overflow-x: auto;
contain: layout paint;
padding-block: var(--s2a-spacing-24);
/* stylelint-disable-next-line property-no-unknown */
view-timeline: --logo-ticker-view block;
padding-block: var(--logo-ticker-inset);

.logo-ticker-wrap {
width: 100%;
overflow: hidden;
}

.logo-ticker-wrap:not(:has(.is-static)) {
mask-image: linear-gradient(
to right,
black 0,
black calc(100% - var(--logo-ticker-mask-width) - var(--logo-ticker-inset)),
transparent calc(100% - var(--logo-ticker-mask-width))
);
}

.logo-ticker-track {
display: flex;
Expand All @@ -28,51 +42,120 @@
}
}

&.dark svg *,
.dark & svg * {
fill: var(--s2a-color-gray-25);
}

span.icon,
svg,
.logo-ticker-track svg,
picture img {
height: var(--logo-ticker-logo-height);
width: auto;
flex: 0 0 auto;
}

.logo-ticker-track:not(.is-static) {
animation: logo-ticker-scroll var(--logo-ticker-duration) linear infinite;
animation-play-state: var(--logo-ticker-play-state, running);

.logo-ticker-set:not(:first-child) {
display: flex;
}
}

.logo-ticker-track.is-static {
width: 100%;
box-sizing: border-box;
justify-content: center;
padding-inline: var(--logo-ticker-gap);
}

.logo-ticker-play-pause {
position: absolute;
top: 50%;
translate: 0 -50%;
inset-inline-end: var(--logo-ticker-inset);
width: var(--logo-ticker-btn-size);
height: var(--logo-ticker-btn-size);
border-radius: var(--s2a-border-radius-2xs);
border: var(--s2a-border-width-sm) solid var(--s2a-color-transparent-black-08);
background: var(--s2a-color-transparent-black-04);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;

&:hover {
border-color: var(--s2a-color-transparent-black-16);
background: var(--s2a-color-transparent-black-08);
transform: scale(1.05);
}

&:active { transform: scale(0.98); }

.logo-ticker-play-icon,
.logo-ticker-pause-icon {
display: block;
width: 1.125rem;
height: 1.125rem;
}

&.is-playing .logo-ticker-play-icon,
&:not(.is-playing) .logo-ticker-pause-icon {
display: none;
}

&:focus-visible {
outline: 2px solid var(--s2a-color-focus-ring-default);
outline-offset: 2px;
}
}

&.dark .logo-ticker-play-pause,
.dark & .logo-ticker-play-pause {
border-radius: var(--s2a-border-radius-6);
border-color: var(--s2a-color-transparent-white-24);
background: var(--s2a-color-transparent-black-64);
color: var(--s2a-color-gray-25);

&:hover {
background: var(--s2a-color-transparent-black-80);
}
}

.logo-ticker-wrap:has(.is-static) ~ .logo-ticker-play-pause {
display: none;
}
}

:dir(rtl) .logo-ticker {
--logo-ticker-dir: -1;

.logo-ticker-wrap:not(:has(.is-static)) {
mask-image: linear-gradient(
to left,
black 0,
black calc(100% - var(--logo-ticker-mask-width) - var(--logo-ticker-inset)),
transparent calc(100% - var(--logo-ticker-mask-width))
);
}
}

@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.logo-ticker {
overflow: clip;

.logo-ticker-set:not(:first-child) {
display: flex;
}

.logo-ticker-track.is-static .logo-ticker-set:not(:first-child) {
display: none;
}

.logo-ticker-track:not(.is-static) {
animation: logo-ticker-drift linear 1;
/* stylelint-disable-next-line property-no-unknown */
animation-timeline: --logo-ticker-view;
/* stylelint-disable-next-line property-no-unknown */
animation-range: cover 0% cover 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.logo-ticker {
--logo-ticker-play-state: paused;
}
}

@keyframes logo-ticker-drift {
from { translate: calc(var(--logo-ticker-drift-distance) * var(--logo-ticker-dir)); }
to { translate: calc(calc(-1 * var(--logo-ticker-drift-distance)) * var(--logo-ticker-dir)); }
}
@keyframes logo-ticker-scroll {
from {
translate: calc(var(--logo-ticker-scroll-offset, 0px) * -1 * var(--logo-ticker-dir));
}

to {
translate: calc((var(--logo-ticker-set-width, 0px) + var(--logo-ticker-scroll-offset, 0px)) * -1 * var(--logo-ticker-dir));
}
}
102 changes: 89 additions & 13 deletions libs/c2/blocks/logo-ticker/logo-ticker.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { createTag } from '../../../utils/utils.js';

const SET_COUNT = 2;
const SET_COUNT = 3;

Comment thread
JingleH marked this conversation as resolved.
function buildTrack(el, logos) {
const PLAY_SVG = '<svg class="logo-ticker-play-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M8 5l11 7-11 7z" fill="currentColor"/></svg>';
const PAUSE_SVG = '<svg class="logo-ticker-pause-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="8" y="5" width="3" height="14" rx="1" fill="currentColor"/><rect x="13" y="5" width="3" height="14" rx="1" fill="currentColor"/></svg>';

function parseAuthoring(el) {
const rows = [...el.children];
const logos = [...rows[0]?.querySelectorAll('span.icon') ?? []];
const [trackLabel = '', playLabel = 'Play logos', pauseLabel = 'Pause logos'] = (rows[1]?.textContent ?? '').split('||').map((s) => s.trim());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could these strings be defined via placeholders instead? Or is GWP already aware of this new authoring pattern?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

waiting for GWP to confirm if they will take this update, but I saw that the feds placeholders sheet isn't really actively used? And putting it in a consumer's sheet feels tricky as this lives in Milo and shared by both cc and dc, so I thought that carrying the strings inside the block feels more self-contained and easier for localization. But maybe there's a separate sheet I can use? Is there another milo placeholders that will be loaded for everyone? Thanks @overmyheadandbody !

return { logos, trackLabel, playLabel, pauseLabel };
}

function buildTrack(logos, trackLabel) {
const logoSets = Array.from({ length: SET_COUNT }, (_, i) => {
const logoSet = createTag('div', { class: 'logo-ticker-set' });
if (i > 0) logoSet.setAttribute('aria-hidden', 'true');
Expand All @@ -11,30 +21,96 @@ function buildTrack(el, logos) {
});

const track = createTag('div', { class: 'logo-ticker-track', role: 'img' }, logoSets);
if (el.children.length >= 2) {
track.setAttribute('aria-label', el.children[1].textContent);
}
if (trackLabel) track.setAttribute('aria-label', trackLabel);
return track;
}

function syncTrackMetrics(track) {
function buildPlayPauseButton(pauseLabel) {
return createTag('button', {
class: 'logo-ticker-play-pause is-playing',
'aria-label': pauseLabel,
'aria-pressed': 'true',
}, `${PLAY_SVG}${PAUSE_SVG}`);
}

function initPlayPause(button, el, playLabel, pauseLabel) {
const reducedMotionMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
let isPlaying = !reducedMotionMQ.matches;

const update = (playing) => {
isPlaying = playing;
el.style.setProperty('--logo-ticker-play-state', playing ? 'running' : 'paused');
button.setAttribute('aria-label', playing ? pauseLabel : playLabel);
button.setAttribute('aria-pressed', String(playing));
button.classList.toggle('is-playing', playing);
};

button.addEventListener('click', () => update(!isPlaying));
reducedMotionMQ.addEventListener('change', ({ matches }) => update(!matches));

update(isPlaying);
}

function addScrollBoost(track, el, getIsPlaying) {
let targetOffset = 0;
let currentOffset = 0;
let lastScrollY = window.scrollY;
let isVisible = false;
let rafId = null;

new IntersectionObserver(([entry]) => { isVisible = entry.isIntersecting; }).observe(el);

const step = () => {
const y = window.lenis?.animatedScroll ?? window.scrollY;
const delta = y - lastScrollY;
lastScrollY = y;

if (getIsPlaying() && !track.classList.contains('is-static') && delta !== 0) {
const setWidth = parseFloat(track.style.getPropertyValue('--logo-ticker-set-width')) || Infinity;
targetOffset = Math.max(-setWidth, Math.min(targetOffset + delta * 0.6, setWidth));
}

currentOffset += (targetOffset - currentOffset) * 0.08;
el.style.setProperty('--logo-ticker-scroll-offset', `${currentOffset}px`);

if (Math.abs(targetOffset - currentOffset) > 0.1) {
rafId = requestAnimationFrame(step);
} else {
rafId = null;
}
};

window.addEventListener('scroll', () => {
if (!isVisible || rafId) return;
rafId = requestAnimationFrame(step);
}, { passive: true });
}

function syncTrackMetrics(track, el) {
const firstSet = track.firstElementChild;
if (!firstSet) return;
const gap = parseFloat(getComputedStyle(track).columnGap) || 0;
const setWidth = firstSet.offsetWidth;
const containerWidth = track.parentElement?.clientWidth || 0;
track.classList.toggle('is-static', setWidth + 2 * gap <= containerWidth);
const isStatic = setWidth + 2 * gap <= el.clientWidth;
track.classList.toggle('is-static', isStatic);
track.style.setProperty('--logo-ticker-set-width', `${setWidth + gap}px`);
}

export default function init(el) {
const logos = [...el.querySelectorAll('span.icon')];
const { logos, trackLabel, playLabel, pauseLabel } = parseAuthoring(el);
if (!logos.length) return;

const track = buildTrack(el, logos);
el.replaceChildren(track);
const track = buildTrack(logos, trackLabel);
const wrap = createTag('div', { class: 'logo-ticker-wrap' });
wrap.append(track);
const button = buildPlayPauseButton(pauseLabel);
el.replaceChildren(wrap, button);

syncTrackMetrics(track);
const ro = new ResizeObserver(() => syncTrackMetrics(track));
syncTrackMetrics(track, el);
const ro = new ResizeObserver(() => syncTrackMetrics(track, el));
ro.observe(track.firstElementChild);
ro.observe(el);

initPlayPause(button, el, playLabel, pauseLabel);
addScrollBoost(track, el, () => button.classList.contains('is-playing'));
}
Loading
Loading