Skip to content

Commit 2d17a49

Browse files
ycanalesCristian Yáñez
andauthored
fix(2312): make testimonial card work without JS (boostorg#2360)
Co-authored-by: Cristian Yáñez <cyanezc@ripley.com>
1 parent 939d055 commit 2d17a49

4 files changed

Lines changed: 248 additions & 43 deletions

File tree

core/mock_data.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -198,39 +198,39 @@ class SharedResources:
198198

199199
testimonials = [
200200
{
201-
"quote": "I use Boost daily. I absolutely love it. It's wonderful. I could not do my job w/o it. Much of it is in the new C++11 standard too.",
201+
"quote": "1 — I use Boost daily. I absolutely love it. It's wonderful. I could not do my job without it. Much of it is in the new C++11 standard too.",
202202
"author": {
203-
"name": "Name Surname",
203+
"name": "Ada Lovelace",
204204
"profile_url": "#",
205205
"avatar_url": "/static/img/v3/demo_page/Avatar.png",
206206
"role": "Contributor",
207207
"badge_url": "/static/img/v3/demo_page/Badge.svg",
208208
},
209209
},
210210
{
211-
"quote": "I use Boost daily. I absolutely love it. It's wonderful. I could not do my job w/o it. Much of it is in the new C++11 standard too.",
211+
"quote": "2 — Boost.Asio changed how I write networked services. The async model is elegant, the docs are thorough, and the community is incredibly responsive.",
212212
"author": {
213-
"name": "Name Surname",
213+
"name": "Grace Hopper",
214214
"profile_url": "#",
215215
"avatar_url": "/static/img/v3/demo_page/Avatar.png",
216-
"role": "Contributor",
216+
"role": "Maintainer",
217217
"badge_url": "/static/img/v3/demo_page/Badge.svg",
218218
},
219219
},
220220
{
221-
"quote": "I use Boost d1aily. I absolutely love it. It's wonderful. I could not do my job w/o it. Much of it is in the new C++11 standard too.",
221+
"quote": "3 — Every serious C++ codebase I've worked on has leaned on Boost in some way. It's the proving ground where tomorrow's standard library takes shape.",
222222
"author": {
223-
"name": "Name Surname",
223+
"name": "Linus Torvalds",
224224
"profile_url": "#",
225225
"avatar_url": "/static/img/v3/demo_page/Avatar.png",
226-
"role": "Contributor",
226+
"role": "Reviewer",
227227
"badge_url": "/static/img/v3/demo_page/Badge.svg",
228228
},
229229
},
230230
{
231-
"quote": "I use Boost daily. I absolutely love it. It's wonderful. I could not do my job w/o it. Much of it is in the new C++11 standard too.",
231+
"quote": "4 — Contributing to Boost taught me more about writing portable, peer-reviewed C++ than any textbook ever could.",
232232
"author": {
233-
"name": "Name Surname",
233+
"name": "Margaret Hamilton",
234234
"profile_url": "#",
235235
"avatar_url": "/static/img/v3/demo_page/Avatar.png",
236236
"role": "Contributor",

static/css/v3/testimonial-card.css

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,78 @@
126126
.testimonial-card__cards {
127127
max-width: 100%;
128128
}
129+
130+
/*
131+
* Carousel navigation uses hidden radios + <label> arrows so clicking an arrow
132+
* doesn't change the URL or trigger scrollIntoView (which would scroll the
133+
* page). The nav pair matching the currently-:checked radio is the only one
134+
* shown. The track keeps `overflow-x: auto` so touch/swipe still works; JS
135+
* syncs radio <-> scroll position. In no-JS mode the track is switched to
136+
* `overflow: hidden` + CSS transform (see the <noscript> block in
137+
* _testimonial_card.html), which trades touch scroll for a working arrow UI.
138+
*/
139+
.testimonial-card__state {
140+
position: absolute;
141+
width: 1px;
142+
height: 1px;
143+
margin: -1px;
144+
padding: 0;
145+
border: 0;
146+
clip: rect(0 0 0 0);
147+
clip-path: inset(50%);
148+
overflow: hidden;
149+
white-space: nowrap;
150+
}
151+
152+
.testimonial-card__controls {
153+
display: contents;
154+
}
155+
156+
.testimonial-card .testimonial-card__nav-set {
157+
display: none;
158+
}
159+
160+
/* Per-index `display: inline-flex` overrides for the checked radio's nav-set
161+
are generated in _testimonial_card.html (the testimonials count is
162+
data-driven, so the rule set is rendered inline by the template). */
163+
164+
.carousel-buttons label.btn-carousel {
165+
display: inline-flex;
166+
align-items: center;
167+
justify-content: center;
168+
color: inherit;
169+
text-decoration: none;
170+
cursor: pointer;
171+
}
172+
173+
.carousel-buttons .btn-carousel[aria-disabled="true"] {
174+
opacity: 0.5;
175+
cursor: not-allowed;
176+
pointer-events: none;
177+
}
178+
179+
/* Focus mirroring: the radios are visually hidden but remain keyboard-focusable
180+
(arrow keys move between them natively). When that happens we reflect focus
181+
onto the currently-visible nav-set wrapper so keyboard users see where they
182+
are. Outlining the wrapper (not each button) avoids two outlines colliding
183+
on the adjacent prev/next pills. Only the nav-set matching the checked
184+
radio is `display: inline-flex`; the others are `display: none`, so a
185+
single rule suffices. */
186+
.testimonial-card:has(.testimonial-card__state:focus-visible) .testimonial-card__nav-set {
187+
outline: 2px solid currentColor;
188+
outline-offset: 2px;
189+
border-radius: 8px;
190+
}
191+
192+
/* Scroll-snap: align items when the user swipes. JS uses smooth scroll on
193+
arrow-click so we don't need scroll-behavior here.
194+
`flex: 0 0 100%` forces each item to exactly the flex container width so
195+
both native scroll and the no-JS transform math land on item boundaries. */
196+
.testimonial-card__list {
197+
scroll-snap-type: x mandatory;
198+
}
199+
200+
.testimonial-card__list-item {
201+
scroll-snap-align: start;
202+
flex: 0 0 100%;
203+
}

static/js/carousel.js

Lines changed: 100 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,33 @@
77
const DEFAULT_AUTOPLAY_MS = 4000;
88
const CAROUSEL_ITEM_SELECTOR = '[data-carousel-item]';
99

10-
function updateArrowState(track, prevBtn, nextBtn) {
11-
if (!track || !prevBtn || !nextBtn) return;
12-
const maxScroll = track.scrollWidth - track.clientWidth;
13-
if (maxScroll <= 0) {
14-
prevBtn.disabled = true;
15-
nextBtn.disabled = true;
16-
return;
10+
function setDisabled(btn, disabled) {
11+
if (!btn) return;
12+
if (btn.tagName === 'BUTTON') {
13+
btn.disabled = disabled;
14+
} else {
15+
if (disabled) {
16+
btn.setAttribute('aria-disabled', 'true');
17+
} else {
18+
btn.removeAttribute('aria-disabled');
19+
}
1720
}
18-
const atStart = track.scrollLeft <= 0;
19-
const atEnd = track.scrollLeft >= maxScroll - SCROLL_END_EPSILON;
20-
prevBtn.disabled = atStart;
21-
nextBtn.disabled = atEnd;
21+
}
22+
23+
function isDisabled(btn) {
24+
if (!btn) return true;
25+
if (btn.tagName === 'BUTTON') return btn.disabled;
26+
return btn.getAttribute('aria-disabled') === 'true';
27+
}
28+
29+
function updateArrowState(track, prevBtns, nextBtns) {
30+
if (!track) return;
31+
const maxScroll = track.scrollWidth - track.clientWidth;
32+
const noScroll = maxScroll <= 0;
33+
const atStart = !noScroll && track.scrollLeft <= 0;
34+
const atEnd = !noScroll && track.scrollLeft >= maxScroll - SCROLL_END_EPSILON;
35+
prevBtns.forEach(function (b) { setDisabled(b, noScroll || atStart); });
36+
nextBtns.forEach(function (b) { setDisabled(b, noScroll || atEnd); });
2237
}
2338

2439
function getStepPx(track) {
@@ -100,11 +115,12 @@
100115
});
101116

102117
const controls = document.getElementById(root.id + '-controls');
103-
const prevBtn = controls && controls.querySelector('[data-carousel-prev]');
104-
const nextBtn = controls && controls.querySelector('[data-carousel-next]');
118+
const prevBtns = controls ? controls.querySelectorAll('[data-carousel-prev]') : [];
119+
const nextBtns = controls ? controls.querySelectorAll('[data-carousel-next]') : [];
105120

106-
if (prevBtn) {
107-
prevBtn.addEventListener('click', function () {
121+
prevBtns.forEach(function (prevBtn) {
122+
prevBtn.addEventListener('click', function (e) {
123+
e.preventDefault();
108124
if (track.scrollLeft < step) {
109125
scrollResetInProgress = true;
110126
track.scrollLeft = setWidth;
@@ -116,13 +132,14 @@
116132
scrollCarousel(track, 'prev', true);
117133
}
118134
});
119-
}
135+
});
120136

121-
if (nextBtn) {
122-
nextBtn.addEventListener('click', function () {
137+
nextBtns.forEach(function (nextBtn) {
138+
nextBtn.addEventListener('click', function (e) {
139+
e.preventDefault();
123140
scrollCarousel(track, 'next', true);
124141
});
125-
}
142+
});
126143

127144
const autoplayDelay = root.getAttribute('data-carousel-autoplay');
128145
if (autoplayDelay) {
@@ -139,34 +156,87 @@
139156
}
140157
}
141158

159+
function setupRadioCarousel(root, track) {
160+
const radios = root.querySelectorAll('input[type="radio"].testimonial-card__state');
161+
if (radios.length === 0) return;
162+
const items = track.querySelectorAll(CAROUSEL_ITEM_SELECTOR);
163+
164+
let scrolling = false;
165+
let scrollTimeout = null;
166+
167+
function syncInert(activeIdx) {
168+
items.forEach(function (item, i) {
169+
if (i === activeIdx) item.removeAttribute('inert');
170+
else item.setAttribute('inert', '');
171+
});
172+
}
173+
174+
const initialIdx = Array.prototype.findIndex.call(radios, function (r) { return r.checked; });
175+
syncInert(initialIdx >= 0 ? initialIdx : 0);
176+
177+
radios.forEach(function (radio, idx) {
178+
radio.addEventListener('change', function () {
179+
if (!radio.checked) return;
180+
syncInert(idx);
181+
const target = items[idx];
182+
if (!target) return;
183+
scrolling = true;
184+
target.scrollIntoView({ inline: 'start', block: 'nearest', behavior: 'smooth' });
185+
clearTimeout(scrollTimeout);
186+
scrollTimeout = setTimeout(function () { scrolling = false; }, 400);
187+
});
188+
});
189+
190+
track.addEventListener('scroll', function () {
191+
if (scrolling) return;
192+
const first = items[0];
193+
if (!first) return;
194+
const itemWidth = first.offsetWidth;
195+
if (itemWidth === 0) return;
196+
const idx = Math.round(track.scrollLeft / itemWidth);
197+
const radio = radios[Math.max(0, Math.min(radios.length - 1, idx))];
198+
if (radio && !radio.checked) {
199+
radio.checked = true;
200+
syncInert(idx);
201+
}
202+
}, { passive: true });
203+
}
204+
142205
function initCarousel(root) {
143206
if (!root || !root.id) return;
144207
const track = root.querySelector('[data-carousel-track]');
145208
const controls = document.getElementById(root.id + '-controls');
146209
if (!track || !controls) return;
147210

211+
if (root.hasAttribute('data-carousel-radios')) {
212+
setupRadioCarousel(root, track);
213+
return;
214+
}
215+
148216
if (root.hasAttribute('data-carousel-infinite')) {
149217
setupInfiniteCarousel(root, track);
150218
return;
151219
}
152220

153-
const prevBtn = controls.querySelector('[data-carousel-prev]');
154-
const nextBtn = controls.querySelector('[data-carousel-next]');
155-
if (prevBtn) {
156-
prevBtn.addEventListener('click', function () {
157-
if (prevBtn.disabled) return;
221+
const prevBtns = controls.querySelectorAll('[data-carousel-prev]');
222+
const nextBtns = controls.querySelectorAll('[data-carousel-next]');
223+
prevBtns.forEach(function (prevBtn) {
224+
prevBtn.addEventListener('click', function (e) {
225+
e.preventDefault();
226+
if (isDisabled(prevBtn)) return;
158227
scrollCarousel(track, 'prev', true);
159228
});
160-
}
161-
if (nextBtn) {
162-
nextBtn.addEventListener('click', function () {
163-
if (nextBtn.disabled) return;
229+
});
230+
nextBtns.forEach(function (nextBtn) {
231+
nextBtn.addEventListener('click', function (e) {
232+
e.preventDefault();
233+
if (isDisabled(nextBtn)) return;
164234
scrollCarousel(track, 'next', true);
165235
});
166-
}
236+
});
167237

168238
const syncArrows = function () {
169-
updateArrowState(track, prevBtn, nextBtn);
239+
updateArrowState(track, prevBtns, nextBtns);
170240
};
171241
requestAnimationFrame(syncArrows);
172242
track.addEventListener('scroll', syncArrows, { passive: true });

templates/v3/includes/_testimonial_card.html

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,78 @@
1212
{% endcomment %}
1313

1414
{% with carousel_id='_testimonials_cards_carousel' %}
15-
<div class="card testimonial-card py-large" id="{{carousel_id}}" data-carousel role="region" aria-labelledby="{{ carousel_id }}-heading">
15+
{% comment %}
16+
Per-index visibility rules generated from the testimonials list (the count is
17+
data-driven, so they can't live as static rules in the CSS). The first <style>
18+
block runs in both JS and no-JS modes; only the nav-set for the checked radio
19+
is shown. The <noscript> block additionally switches the track to a CSS
20+
transform-based carousel: items are flex: 0 0 100% with gap: 0, so
21+
translateX(-N00%) shifts by exactly one item per radio. visibility: hidden on
22+
non-current items mirrors the JS-mode `inert` behavior so off-screen author
23+
links can't receive tab focus. Touch-swipe is lost in no-JS mode (the parent
24+
`.testimonial-card__post` clips with overflow: hidden) but arrow buttons work
25+
without scrolling the page.
26+
{% endcomment %}
27+
{% if testimonials %}
28+
<style>
29+
{% for testimonial in testimonials %}
30+
.testimonial-card:has(.testimonial-card__state[data-state-index="{{ forloop.counter0 }}"]:checked) .testimonial-card__nav-set[data-nav-index="{{ forloop.counter0 }}"] { display: inline-flex; }
31+
{% endfor %}
32+
</style>
33+
<noscript>
34+
<style>
35+
.testimonial-card__list { overflow: visible !important; gap: 0 !important; transition: transform 0.3s ease; }
36+
.testimonial-card__list-item { visibility: hidden; }
37+
38+
{% for testimonial in testimonials %}
39+
.testimonial-card:has(.testimonial-card__state[data-state-index="{{ forloop.counter0 }}"]:checked) .testimonial-card__list { transform: translateX(-{{ forloop.counter0 }}00%); }
40+
.testimonial-card:has(.testimonial-card__state[data-state-index="{{ forloop.counter0 }}"]:checked) .testimonial-card__list-item[data-item-index="{{ forloop.counter0 }}"] { visibility: visible; }
41+
{% endfor %}
42+
</style>
43+
</noscript>
44+
{% endif %}
45+
<div class="card testimonial-card py-large" id="{{carousel_id}}" data-carousel data-carousel-radios role="region" aria-labelledby="{{ carousel_id }}-heading">
46+
{% if testimonials %}
47+
{% for testimonial in testimonials %}
48+
<input type="radio" class="testimonial-card__state" name="{{ carousel_id }}-state" id="{{ carousel_id }}-state-{{ forloop.counter0 }}" data-state-index="{{ forloop.counter0 }}" {% if forloop.first %}checked{% endif %}>
49+
{% endfor %}
50+
{% endif %}
1651
<div class="card__header">
1752
<h2 id="{{ carousel_id }}-heading" class="card__title">{{ heading }}</h2>
18-
{% include 'v3/includes/_carousel_buttons.html' with carousel_id=carousel_id %}
53+
{% if testimonials %}
54+
<div class="testimonial-card__controls" id="{{ carousel_id }}-controls" role="group" aria-label="Carousel navigation">
55+
{% for testimonial in testimonials %}
56+
<div class="carousel-buttons testimonial-card__nav-set" data-nav-index="{{ forloop.counter0 }}">
57+
{% if forloop.first %}
58+
<span class="btn btn-carousel" aria-disabled="true" aria-label="Previous" data-carousel-prev>
59+
<span class="btn-icon" aria-hidden>{% include "includes/icon.html" with icon_name="chevron-left" icon_size=12 %}</span>
60+
</span>
61+
{% else %}
62+
<label class="btn btn-carousel" for="{{ carousel_id }}-state-{{ forloop.counter0|add:"-1" }}" aria-label="Previous" data-carousel-prev>
63+
<span class="btn-icon" aria-hidden>{% include "includes/icon.html" with icon_name="chevron-left" icon_size=12 %}</span>
64+
</label>
65+
{% endif %}
66+
{% if forloop.last %}
67+
<span class="btn btn-carousel" aria-disabled="true" aria-label="Next" data-carousel-next>
68+
<span class="btn-icon" aria-hidden>{% include "includes/icon.html" with icon_name="chevron-right" icon_size=12 %}</span>
69+
</span>
70+
{% else %}
71+
<label class="btn btn-carousel" for="{{ carousel_id }}-state-{{ forloop.counter }}" aria-label="Next" data-carousel-next>
72+
<span class="btn-icon" aria-hidden>{% include "includes/icon.html" with icon_name="chevron-right" icon_size=12 %}</span>
73+
</label>
74+
{% endif %}
75+
</div>
76+
{% endfor %}
77+
</div>
78+
{% endif %}
1979
</div>
2080
<hr class="card__hr" />
2181
<div class="cards-carousel__track">
2282
<section class="testimonial-card__post">
2383
<ul class="testimonial-card__list" data-carousel-track aria-label="Testimonials carousel">
2484
{% if testimonials %}
2585
{% for testimonial in testimonials %}
26-
<li class="testimonial-card__list-item" data-carousel-item>
86+
<li class="testimonial-card__list-item" data-carousel-item data-item-index="{{ forloop.counter0 }}">
2787
<div>
2888
<div class="testimonial-card__text-wrapper">
2989
<div class="testimonial-card__text">{{ testimonial.quote }}</div>

0 commit comments

Comments
 (0)