<module-carousel style="aspect-ratio: 16 / 9">
<h2 class="visually-hidden">Slides</h2>
<div class="slides">
<div id="slide1" role="tabpanel" aria-current="true">
<h3>Slide 1</h3>
<hello-world>
<label>
Your name<br />
<input type="text" name="name" autocomplete="given-name" />
</label>
<p>Hello, <span>World</span>!</p>
</hello-world>
</div>
<div id="slide2" role="tabpanel" aria-current="false">
<h3>Slide 2</h3>
<calc-table rows="3" columns="3">
<div class="rows">
<p>Number of rows:</p>
<form-spinbutton
value="3"
zero-label="Add Row"
increment-label="Increment"
>
<button
type="button"
class="decrement"
aria-label="Decrement"
>
−
</button>
<p class="value">3</p>
<button
type="button"
class="increment"
aria-label="Increment"
>
+
</button>
</form-spinbutton>
</div>
<div class="columns">
<p>Number of columns:</p>
<form-spinbutton
value="3"
zero-label="Add Column"
increment-label="Increment"
>
<button
type="button"
class="decrement"
aria-label="Decrement"
>
−
</button>
<p class="value">3</p>
<button
type="button"
class="increment"
aria-label="Increment"
>
+
</button>
</form-spinbutton>
</div>
<table>
<thead>
<tr>
<th scope="col">Row</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<th scope="row">Sum</th>
</tr>
</tfoot>
</table>
<template class="calc-table-row">
<tr>
<th scope="row"><slot></slot></th>
</tr>
</template>
<template class="calc-table-colhead">
<th scope="col"><slot></slot></th>
</template>
<template class="calc-table-cell">
<td>
<label>
<span class="visually-hidden"><slot></slot></span>
<input
type="number"
min="0"
max="100"
step="1"
value=""
/>
</label>
</td>
</template>
</calc-table>
</div>
<div id="slide3" role="tabpanel" aria-current="false">
<h3>Slide 3</h3>
<rating-feedback>
<form>
<rating-stars>
<fieldset>
<legend class="visually-hidden">Rate</legend>
<label>
<input
type="radio"
class="visually-hidden"
name="rating"
value="1"
/>
<span class="label">☆</span>
</label>
<label>
<input
type="radio"
class="visually-hidden"
name="rating"
value="2"
/>
<span class="label">☆</span>
</label>
<label>
<input
type="radio"
class="visually-hidden"
name="rating"
value="3"
/>
<span class="label">☆</span>
</label>
<label>
<input
type="radio"
class="visually-hidden"
name="rating"
value="4"
/>
<span class="label">☆</span>
</label>
<label>
<input
type="radio"
class="visually-hidden"
name="rating"
value="5"
/>
<span class="label">☆</span>
</label>
</fieldset>
</rating-stars>
<div class="feedback" hidden>
<header>
<button
button="button"
class="hide"
aria-label="Hide"
>
×
</button>
<p hidden>
We're sorry to hear that! Your feedback is
important, and we'd love to improve. Let us know
how we can do better.
</p>
<p hidden>
Thank you for your honesty. We appreciate your
feedback and will work on making things better.
</p>
<p hidden>
Thanks for your rating! If there's anything we
can improve, we'd love to hear your thoughts.
</p>
<p hidden>
We're glad you had a good experience! If there's
anything that could make it even better, let us
know.
</p>
<p hidden>
Thank you for your support! We're thrilled you
had a great experience. Your feedback keeps us
motivated!
</p>
</header>
<fieldset>
<label for="rating-feedback"
>Describe your experience (optional)</label
>
<textarea id="rating-feedback"></textarea>
<basic-button disabled>
<button type="submit" class="primary" disabled>
Submit
</button>
</basic-button>
</fieldset>
</div>
</form>
</rating-feedback>
</div>
</div>
<nav aria-label="Carousel Navigation">
<button type="button" class="prev" aria-label="Previous">❮</button>
<button type="button" class="next" aria-label="Next">❯</button>
<div role="tablist">
<button
role="tab"
aria-selected="true"
aria-controls="slide1"
aria-label="Slide 1"
data-index="0"
tabindex="0"
>
●
</button>
<button
role="tab"
aria-selected="false"
aria-controls="slide2"
aria-label="Slide 2"
data-index="1"
tabindex="-1"
>
●
</button>
<button
role="tab"
aria-selected="false"
aria-controls="slide3"
aria-label="Slide 3"
data-index="2"
tabindex="-1"
>
●
</button>
</div>
</nav>
</module-carousel>
module-carousel {
display: flex;
position: relative;
overflow: hidden;
margin-block-end: var(--space-l);
border-radius: var(--space-s);
.slides {
display: flex;
align-items: center;
width: 100%;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
overscroll-behavior-x: none;
}
[role="tabpanel"] {
width: 100%;
height: 100%;
text-align: center;
scroll-snap-align: start;
flex: 0 0 100%;
& h3 {
display: block;
}
& a[href].anchor {
justify-content: center;
padding: 0;
}
.slide-content {
width: 80%;
margin: 0 auto 0;
padding-bottom: var(--space-xl);
text-align: left;
}
}
> nav {
> button {
position: absolute;
top: 2%;
height: 96%;
border: 0;
border-radius: var(--space-xs);
background: transparent;
padding: var(--space-m);
font-size: var(--font-size-xl);
color: var(--color-text);
opacity: var(--opacity-dimmed);
transition: opacity var(--transition-short) var(--easing-inout);
cursor: pointer;
&:hover,
&:active,
&:focus {
opacity: var(--opacity-solid);
background-color: rgba(0, 0, 0, 0.05);
}
&:active {
background-color: rgba(0, 0, 0, 0.1);
}
&.prev {
left: 1%;
}
&.next {
right: 1%;
}
}
[role="tablist"] {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
display: flex;
justify-content: center;
padding-block: var(--space-s);
[role="tab"] {
width: var(--space-l);
height: var(--space-l);
border: 0;
padding: 0;
font-size: var(--font-size-l);
line-height: var(--line-height-xs);
border-radius: 50%;
color: var(--color-text);
background-color: transparent;
opacity: var(--opacity-translucent);
transition: opacity var(--transition-short) var(--easing-inout);
cursor: pointer;
&:hover {
opacity: var(--opacity-dimmed);
}
&[aria-selected="true"] {
opacity: var(--opacity-solid);
}
}
}
}
}
import {
asInteger,
type Component,
component,
fromSelector,
on,
setProperty,
} from '../../..'
export type ModuleCarouselProps = {
readonly slides: HTMLElement[]
readonly index: number
}
const wrapAround = (index: number, total: number) => (index + total) % total
export default component(
'module-carousel',
{
slides: fromSelector('[role="tabpanel"]'),
index: asInteger((host: HTMLElement & { slides: HTMLElement[] }) =>
Math.max(
host.slides.findIndex(slide => slide.ariaCurrent === 'true'),
0,
),
),
},
(el, { all }) => {
const isCurrentDot = (target: HTMLElement) =>
target.dataset.index === String(el.index)
const scrollToCurrentSlide = () => {
el.slides[el.index].scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
}
return [
// Register IntersectionObserver to update index based on scroll position
() => {
const observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
if (entry.isIntersecting) {
el.index = el.slides.findIndex(
slide => slide === entry.target,
)
break
}
}
},
{
root: el.querySelector('.slides'),
rootMargin: '0px',
threshold: 0.99, // Ignore rounding errors
},
)
el.slides.forEach(slide => {
observer.observe(slide)
})
return () => {
observer.disconnect()
}
},
// Handle navigation button click and keyup events
all('nav button', [
on('click', ({ host, target }) => {
const total = host.slides.length
const nextIndex = target.classList.contains('prev')
? el.index - 1
: target.classList.contains('next')
? el.index + 1
: parseInt(target.dataset.index || '0')
el.index = Number.isInteger(nextIndex)
? wrapAround(nextIndex, total)
: 0
scrollToCurrentSlide()
}),
on('keyup', ({ event, host }) => {
const key = event.key
if (
['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(key)
) {
event.preventDefault()
event.stopPropagation()
const total = host.slides.length
const nextIndex =
key === 'Home'
? 0
: key === 'End'
? total - 1
: wrapAround(
el.index +
(key === 'ArrowLeft' ? -1 : 1),
total,
)
host.slides[nextIndex].focus()
el.index = nextIndex
scrollToCurrentSlide()
}
}),
]),
// Set the active slide in the navigation
all('[role="tab"]', [
setProperty('ariaSelected', target =>
String(isCurrentDot(target)),
),
setProperty('tabIndex', target =>
isCurrentDot(target) ? 0 : -1,
),
]),
// Set the active slide in the slides
all('[role="tabpanel"]', [
setProperty('ariaCurrent', target =>
String(target.id === el.slides[el.index].id),
),
]),
]
},
)
declare global {
interface HTMLElementTagNameMap {
'module-carousel': Component<ModuleCarouselProps>
}
}